diff --git a/src/backend/base/langflow/inputs/__init__.py b/src/backend/base/langflow/inputs/__init__.py
index c23515cbb..8c0989a6a 100644
--- a/src/backend/base/langflow/inputs/__init__.py
+++ b/src/backend/base/langflow/inputs/__init__.py
@@ -20,6 +20,7 @@ from .inputs import (
SecretStrInput,
StrInput,
TableInput,
+ LinkInput,
)
__all__ = [
@@ -44,4 +45,5 @@ __all__ = [
"TableInput",
"Input",
"DefaultPromptField",
+ "LinkInput",
]
diff --git a/src/backend/base/langflow/inputs/input_mixin.py b/src/backend/base/langflow/inputs/input_mixin.py
index f07c238a3..054a3de5a 100644
--- a/src/backend/base/langflow/inputs/input_mixin.py
+++ b/src/backend/base/langflow/inputs/input_mixin.py
@@ -28,6 +28,7 @@ class FieldTypes(str, Enum):
CODE = "code"
OTHER = "other"
TABLE = "table"
+ LINK = "link"
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]
@@ -156,6 +157,13 @@ class MultilineMixin(BaseModel):
multiline: CoalesceBool = True
+class LinkMixin(BaseModel):
+ icon: str | None = None
+ """Icon to be displayed in the link."""
+ text: str | None = None
+ """Text to be displayed in the link."""
+
+
class TableMixin(BaseModel):
table_schema: TableSchema | list[Column] | None = None
diff --git a/src/backend/base/langflow/inputs/inputs.py b/src/backend/base/langflow/inputs/inputs.py
index 75d31479d..ef797433e 100644
--- a/src/backend/base/langflow/inputs/inputs.py
+++ b/src/backend/base/langflow/inputs/inputs.py
@@ -22,6 +22,7 @@ from .input_mixin import (
RangeMixin,
SerializableFieldTypes,
TableMixin,
+ LinkMixin,
)
@@ -467,6 +468,10 @@ class FileInput(BaseInputMixin, ListableInputMixin, FileMixin, MetadataTraceMixi
field_type: SerializableFieldTypes = FieldTypes.FILE
+class LinkInput(BaseInputMixin, LinkMixin):
+ field_type: SerializableFieldTypes = FieldTypes.LINK
+
+
DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"]
@@ -474,7 +479,6 @@ class DefaultPromptField(Input):
name: str
display_name: str | None = None
field_type: str = "str"
-
advanced: bool = False
multiline: bool = True
input_types: list[str] = DEFAULT_PROMPT_INTUT_TYPES
@@ -503,6 +507,7 @@ InputTypes = Union[
MessageTextInput,
MessageInput,
TableInput,
+ LinkInput,
]
InputTypesMap: dict[str, type[InputTypes]] = {t.__name__: t for t in get_args(InputTypes)}
diff --git a/src/backend/base/langflow/io/__init__.py b/src/backend/base/langflow/io/__init__.py
index 268943a5a..68f99a558 100644
--- a/src/backend/base/langflow/io/__init__.py
+++ b/src/backend/base/langflow/io/__init__.py
@@ -19,6 +19,7 @@ from langflow.inputs import (
StrInput,
TableInput,
DefaultPromptField,
+ LinkInput,
)
from langflow.template import Output
@@ -44,4 +45,5 @@ __all__ = [
"Output",
"TableInput",
"DefaultPromptField",
+ "LinkInput",
]
diff --git a/src/frontend/src/components/linkComponent/index.tsx b/src/frontend/src/components/linkComponent/index.tsx
new file mode 100644
index 000000000..a646fbef9
--- /dev/null
+++ b/src/frontend/src/components/linkComponent/index.tsx
@@ -0,0 +1,65 @@
+import { LinkComponentType } from "@/types/components";
+import { useCallback, useEffect, useState } from "react";
+import { classNames } from "../../utils/utils";
+import IconComponent from "../genericIconComponent";
+import { Button } from "../ui/button";
+
+const DEFAULT_ICON = "ExternalLink";
+
+export default function LinkComponent({
+ value,
+ disabled = false,
+ id = "",
+}: LinkComponentType): JSX.Element {
+ const [componentValue, setComponentValue] = useState(value);
+
+ useEffect(() => {
+ setComponentValue(value);
+ }, [value]);
+
+ const handleOpenLink = useCallback(() => {
+ if (componentValue?.value) {
+ const url = !/^https?:\/\//i.test(componentValue.value)
+ ? `https://${componentValue.value}`
+ : componentValue.value;
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ }, [componentValue]);
+
+ const buttonClassName = classNames(
+ "nopan w-full shrink-0",
+ disabled ? "cursor-not-allowed text-ring" : "hover:text-accent-foreground",
+ );
+
+ const ButtonContent = ({ icon, text }: { icon: string; text: string }) => {
+ return (
+
+
+ {text && {text}}
+
+ );
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/frontend/src/components/parameterRenderComponent/index.tsx b/src/frontend/src/components/parameterRenderComponent/index.tsx
index e4fb0b7a6..5619c27e3 100644
--- a/src/frontend/src/components/parameterRenderComponent/index.tsx
+++ b/src/frontend/src/components/parameterRenderComponent/index.tsx
@@ -9,6 +9,7 @@ import FloatComponent from "../floatComponent";
import InputFileComponent from "../inputFileComponent";
import IntComponent from "../intComponent";
import KeypairListComponent from "../keypairListComponent";
+import LinkComponent from "../linkComponent";
import PromptAreaComponent from "../promptComponent";
import ToggleShadComponent from "../toggleShadComponent";
import { RefreshParameterComponent } from "./component/refreshParameterComponent";
@@ -94,6 +95,12 @@ export function ParameterRenderComponent({
setEnabled={onChange}
size={editNode ? "small" : "large"}
/>
+ ) : templateData.type === "link" ? (
+
) : templateData.type === "float" ? (
void;
setChatHistory: (chatHistory: ChatMessageType) => void;
};
+
+export type LinkComponentType = {
+ value: Partial;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+ editNode?: boolean;
+ id?: string;
+};
diff --git a/src/frontend/tests/core/unit/linkComponent.spec.ts b/src/frontend/tests/core/unit/linkComponent.spec.ts
new file mode 100644
index 000000000..a5a00bd55
--- /dev/null
+++ b/src/frontend/tests/core/unit/linkComponent.spec.ts
@@ -0,0 +1,117 @@
+import { expect, Page, test } from "@playwright/test";
+import uaParser from "ua-parser-js";
+
+test("user should interact with link component", async ({ context, page }) => {
+ await page.goto("/");
+ await page.waitForSelector('[data-testid="mainpage_title"]', {
+ timeout: 30000,
+ });
+
+ await page.waitForSelector('[id="new-project-btn"]', {
+ timeout: 30000,
+ });
+
+ let modalCount = 0;
+ try {
+ const modalTitleElement = await page?.getByTestId("modal-title");
+ if (modalTitleElement) {
+ modalCount = await modalTitleElement.count();
+ }
+ } catch (error) {
+ modalCount = 0;
+ }
+
+ while (modalCount === 0) {
+ await page.getByText("New Project", { exact: true }).click();
+ await page.waitForTimeout(3000);
+ modalCount = await page.getByTestId("modal-title")?.count();
+ }
+
+ const getUA = await page.evaluate(() => navigator.userAgent);
+ const userAgentInfo = uaParser(getUA);
+ let control = "Control";
+
+ await page.waitForSelector('[data-testid="blank-flow"]', {
+ timeout: 30000,
+ });
+ await page.getByTestId("blank-flow").click();
+
+ await page.waitForSelector('[data-testid="extended-disclosure"]', {
+ timeout: 30000,
+ });
+ const focusElementsOnBoard = async ({ page }) => {
+ const focusElements = await page.getByTestId("extended-disclosure");
+ focusElements.click();
+ };
+
+ await focusElementsOnBoard({ page });
+ await page.getByTestId("extended-disclosure").click();
+ await page.getByPlaceholder("Search").click();
+ await page.getByPlaceholder("Search").fill("custom component");
+
+ await page.waitForTimeout(1000);
+
+ await page
+ .locator('//*[@id="helpersCustom Component"]')
+ .dragTo(page.locator('//*[@id="react-flow-id"]'));
+ await page.mouse.up();
+ await page.mouse.down();
+ await page.getByTitle("fit view").click();
+ await page.getByTitle("zoom out").click();
+
+ await page.getByTestId("title-Custom Component").first().click();
+
+ await page.waitForTimeout(500);
+ await page.getByTestId("code-button-modal").click();
+ await page.waitForTimeout(500);
+
+ let cleanCode = await extractAndCleanCode(page);
+
+ // Replace the import statement
+ cleanCode = cleanCode.replace(
+ "from langflow.io import MessageTextInput, Output",
+ "from langflow.io import MessageTextInput, Output, LinkInput",
+ );
+
+ // Replace the MessageTextInput line and add LinkInput
+ cleanCode = cleanCode.replace(
+ 'MessageTextInput(name="input_value", display_name="Input Value", value="Hello, World!"),',
+ `MessageTextInput(name="input_value", display_name="Input Value", value="Hello, World!"),
+ LinkInput(name="link", display_name="BUTTON", value="https://www.datastax.com", text="Click me"),`,
+ );
+
+ await page.locator("textarea").last().press(`Meta+a`);
+ await page.keyboard.press("Backspace");
+ await page.locator("textarea").last().fill(cleanCode);
+ await page.locator('//*[@id="checkAndSaveBtn"]').click();
+ await page.waitForTimeout(500);
+
+ await page.getByTitle("fit view").click();
+ await page.getByTitle("zoom out").click();
+
+ expect(await page.getByText("BUTTON").isVisible()).toBeTruthy();
+ expect(await page.getByText("Click me").isVisible()).toBeTruthy();
+ expect(await page.getByTestId("link_link_link")).toBeEnabled();
+ await page.getByTestId("link_link_link").click();
+});
+
+async function extractAndCleanCode(page: Page): Promise {
+ const outerHTML = await page
+ .locator('//*[@id="codeValue"]')
+ .evaluate((el) => el.outerHTML);
+
+ const valueMatch = outerHTML.match(/value="([\s\S]*?)"/);
+ if (!valueMatch) {
+ throw new Error("Could not find value attribute in the HTML");
+ }
+
+ let codeContent = valueMatch[1]
+ .replace(/"/g, '"')
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/'/g, "'")
+ .replace(///g, "/");
+
+ return codeContent;
+}
diff --git a/src/frontend/tests/templates/unit-test-components.spec.ts b/src/frontend/tests/templates/unit-test-components.spec.ts
new file mode 100644
index 000000000..b7a8be05b
--- /dev/null
+++ b/src/frontend/tests/templates/unit-test-components.spec.ts
@@ -0,0 +1,3 @@
+import { test } from "@playwright/test";
+
+test("your_test_name", async ({ page }) => {});