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 ( +
+
+ ); + }; + + 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 }) => {});