feat: Add New Link Parameter Type for External Links in Node UI (#3806)

*  (inputs/__init__.py): Add LinkInput class to support linking functionality in inputs
📝 (inputs/input_mixin.py): Add Link field type to support linking functionality in inputs
📝 (inputs/inputs.py): Add LinkInput class to support linking functionality in inputs
📝 (io/__init__.py): Import and export LinkInput class for linking functionality in inputs
📝 (frontend/src/components/linkComponent/index.tsx): Create LinkComponent to display and handle links in frontend components
📝 (frontend/src/components/parameterRenderComponent/index.tsx): Add support for rendering LinkComponent in parameter rendering based on template type
📝 (frontend/src/constants/constants.ts): Add "link" as a supported type for LANGFLOW_SUPPORTED_TYPES
📝 (frontend/src/types/api/index.ts): Add icon and text fields to InputFieldType for link component
📝 (frontend/src/types/components/index.ts): Define LinkComponentType for passing link data to LinkComponent

 (linkComponent.spec.ts): Add unit test for link component interaction to ensure proper functionality and behavior
📝 (unit-test-components.spec.ts): Add template for a unit test file to be used for testing components in the frontend application

* style: apply make format

* Update input_mixin.py

---------

Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com>
This commit is contained in:
Cristhian Zanforlin Lousa 2024-09-17 13:56:10 -03:00 committed by GitHub
commit 3e617acf2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 221 additions and 1 deletions

View file

@ -20,6 +20,7 @@ from .inputs import (
SecretStrInput,
StrInput,
TableInput,
LinkInput,
)
__all__ = [
@ -44,4 +45,5 @@ __all__ = [
"TableInput",
"Input",
"DefaultPromptField",
"LinkInput",
]

View file

@ -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

View file

@ -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)}

View file

@ -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",
]

View file

@ -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 (
<div className="flex items-center gap-2">
<IconComponent
name={icon ?? DEFAULT_ICON}
className="h-5 w-5"
aria-hidden="true"
/>
{text && <span>{text}</span>}
</div>
);
};
return (
<div className="flex w-full items-center gap-3">
<Button
data-testid={id}
onClick={handleOpenLink}
disabled={disabled || !componentValue}
type="button"
variant="primary"
size="sm"
className={buttonClassName}
>
<ButtonContent
icon={componentValue?.icon ?? DEFAULT_ICON}
text={componentValue?.text ?? ""}
/>
</Button>
</div>
);
}

View file

@ -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" ? (
<LinkComponent
value={templateData}
onChange={onChange}
id={`link_${id}`}
/>
) : templateData.type === "float" ? (
<FloatComponent
disabled={disabled}

View file

@ -650,6 +650,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"dict",
"NestedDict",
"table",
"link",
]);
export const priorityFields = new Set(["code", "template"]);

View file

@ -84,6 +84,8 @@ export type InputFieldType = {
combobox?: boolean;
info?: string;
[key: string]: any;
icon?: string;
text?: string;
};
export type OutputFieldProxyType = {

View file

@ -879,3 +879,11 @@ export type handleSelectPropsType = {
setLockChat: (lock: boolean) => void;
setChatHistory: (chatHistory: ChatMessageType) => void;
};
export type LinkComponentType = {
value: Partial<InputFieldType>;
onChange: (value: string) => void;
disabled?: boolean;
editNode?: boolean;
id?: string;
};

View file

@ -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<string> {
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(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/");
return codeContent;
}

View file

@ -0,0 +1,3 @@
import { test } from "@playwright/test";
test("your_test_name", async ({ page }) => {});