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:
parent
d4bed03f9c
commit
3e617acf2f
11 changed files with 221 additions and 1 deletions
|
|
@ -20,6 +20,7 @@ from .inputs import (
|
||||||
SecretStrInput,
|
SecretStrInput,
|
||||||
StrInput,
|
StrInput,
|
||||||
TableInput,
|
TableInput,
|
||||||
|
LinkInput,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -44,4 +45,5 @@ __all__ = [
|
||||||
"TableInput",
|
"TableInput",
|
||||||
"Input",
|
"Input",
|
||||||
"DefaultPromptField",
|
"DefaultPromptField",
|
||||||
|
"LinkInput",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class FieldTypes(str, Enum):
|
||||||
CODE = "code"
|
CODE = "code"
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
TABLE = "table"
|
TABLE = "table"
|
||||||
|
LINK = "link"
|
||||||
|
|
||||||
|
|
||||||
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]
|
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]
|
||||||
|
|
@ -156,6 +157,13 @@ class MultilineMixin(BaseModel):
|
||||||
multiline: CoalesceBool = True
|
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):
|
class TableMixin(BaseModel):
|
||||||
table_schema: TableSchema | list[Column] | None = None
|
table_schema: TableSchema | list[Column] | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from .input_mixin import (
|
||||||
RangeMixin,
|
RangeMixin,
|
||||||
SerializableFieldTypes,
|
SerializableFieldTypes,
|
||||||
TableMixin,
|
TableMixin,
|
||||||
|
LinkMixin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -467,6 +468,10 @@ class FileInput(BaseInputMixin, ListableInputMixin, FileMixin, MetadataTraceMixi
|
||||||
field_type: SerializableFieldTypes = FieldTypes.FILE
|
field_type: SerializableFieldTypes = FieldTypes.FILE
|
||||||
|
|
||||||
|
|
||||||
|
class LinkInput(BaseInputMixin, LinkMixin):
|
||||||
|
field_type: SerializableFieldTypes = FieldTypes.LINK
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"]
|
DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -474,7 +479,6 @@ class DefaultPromptField(Input):
|
||||||
name: str
|
name: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
field_type: str = "str"
|
field_type: str = "str"
|
||||||
|
|
||||||
advanced: bool = False
|
advanced: bool = False
|
||||||
multiline: bool = True
|
multiline: bool = True
|
||||||
input_types: list[str] = DEFAULT_PROMPT_INTUT_TYPES
|
input_types: list[str] = DEFAULT_PROMPT_INTUT_TYPES
|
||||||
|
|
@ -503,6 +507,7 @@ InputTypes = Union[
|
||||||
MessageTextInput,
|
MessageTextInput,
|
||||||
MessageInput,
|
MessageInput,
|
||||||
TableInput,
|
TableInput,
|
||||||
|
LinkInput,
|
||||||
]
|
]
|
||||||
|
|
||||||
InputTypesMap: dict[str, type[InputTypes]] = {t.__name__: t for t in get_args(InputTypes)}
|
InputTypesMap: dict[str, type[InputTypes]] = {t.__name__: t for t in get_args(InputTypes)}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from langflow.inputs import (
|
||||||
StrInput,
|
StrInput,
|
||||||
TableInput,
|
TableInput,
|
||||||
DefaultPromptField,
|
DefaultPromptField,
|
||||||
|
LinkInput,
|
||||||
)
|
)
|
||||||
from langflow.template import Output
|
from langflow.template import Output
|
||||||
|
|
||||||
|
|
@ -44,4 +45,5 @@ __all__ = [
|
||||||
"Output",
|
"Output",
|
||||||
"TableInput",
|
"TableInput",
|
||||||
"DefaultPromptField",
|
"DefaultPromptField",
|
||||||
|
"LinkInput",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
65
src/frontend/src/components/linkComponent/index.tsx
Normal file
65
src/frontend/src/components/linkComponent/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import FloatComponent from "../floatComponent";
|
||||||
import InputFileComponent from "../inputFileComponent";
|
import InputFileComponent from "../inputFileComponent";
|
||||||
import IntComponent from "../intComponent";
|
import IntComponent from "../intComponent";
|
||||||
import KeypairListComponent from "../keypairListComponent";
|
import KeypairListComponent from "../keypairListComponent";
|
||||||
|
import LinkComponent from "../linkComponent";
|
||||||
import PromptAreaComponent from "../promptComponent";
|
import PromptAreaComponent from "../promptComponent";
|
||||||
import ToggleShadComponent from "../toggleShadComponent";
|
import ToggleShadComponent from "../toggleShadComponent";
|
||||||
import { RefreshParameterComponent } from "./component/refreshParameterComponent";
|
import { RefreshParameterComponent } from "./component/refreshParameterComponent";
|
||||||
|
|
@ -94,6 +95,12 @@ export function ParameterRenderComponent({
|
||||||
setEnabled={onChange}
|
setEnabled={onChange}
|
||||||
size={editNode ? "small" : "large"}
|
size={editNode ? "small" : "large"}
|
||||||
/>
|
/>
|
||||||
|
) : templateData.type === "link" ? (
|
||||||
|
<LinkComponent
|
||||||
|
value={templateData}
|
||||||
|
onChange={onChange}
|
||||||
|
id={`link_${id}`}
|
||||||
|
/>
|
||||||
) : templateData.type === "float" ? (
|
) : templateData.type === "float" ? (
|
||||||
<FloatComponent
|
<FloatComponent
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -650,6 +650,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
||||||
"dict",
|
"dict",
|
||||||
"NestedDict",
|
"NestedDict",
|
||||||
"table",
|
"table",
|
||||||
|
"link",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const priorityFields = new Set(["code", "template"]);
|
export const priorityFields = new Set(["code", "template"]);
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export type InputFieldType = {
|
||||||
combobox?: boolean;
|
combobox?: boolean;
|
||||||
info?: string;
|
info?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
icon?: string;
|
||||||
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutputFieldProxyType = {
|
export type OutputFieldProxyType = {
|
||||||
|
|
|
||||||
|
|
@ -879,3 +879,11 @@ export type handleSelectPropsType = {
|
||||||
setLockChat: (lock: boolean) => void;
|
setLockChat: (lock: boolean) => void;
|
||||||
setChatHistory: (chatHistory: ChatMessageType) => void;
|
setChatHistory: (chatHistory: ChatMessageType) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LinkComponentType = {
|
||||||
|
value: Partial<InputFieldType>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
editNode?: boolean;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
117
src/frontend/tests/core/unit/linkComponent.spec.ts
Normal file
117
src/frontend/tests/core/unit/linkComponent.spec.ts
Normal 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(/"/g, '"')
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///g, "/");
|
||||||
|
|
||||||
|
return codeContent;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("your_test_name", async ({ page }) => {});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue