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,
|
||||
StrInput,
|
||||
TableInput,
|
||||
LinkInput,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -44,4 +45,5 @@ __all__ = [
|
|||
"TableInput",
|
||||
"Input",
|
||||
"DefaultPromptField",
|
||||
"LinkInput",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
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 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}
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
|||
"dict",
|
||||
"NestedDict",
|
||||
"table",
|
||||
"link",
|
||||
]);
|
||||
|
||||
export const priorityFields = new Set(["code", "template"]);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export type InputFieldType = {
|
|||
combobox?: boolean;
|
||||
info?: string;
|
||||
[key: string]: any;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type OutputFieldProxyType = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
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