From 51b3909d60c2421cb256d83c5e04f9dc4036c3bd Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Wed, 16 Oct 2024 11:00:27 -0300 Subject: [PATCH] feat: add new input type - SliderComponent + tests (#4144) --- src/backend/base/langflow/inputs/__init__.py | 5 + .../base/langflow/inputs/input_mixin.py | 11 + src/backend/base/langflow/inputs/inputs.py | 6 + src/backend/base/langflow/io/__init__.py | 4 + src/frontend/package-lock.json | 49 ++++ src/frontend/package.json | 1 + .../parameterRenderComponent/index.tsx | 18 ++ .../src/components/sliderComponent/index.tsx | 254 ++++++++++++++++++ .../utils/get-min-max-value.ts | 16 ++ src/frontend/src/components/ui/slider.tsx | 27 ++ src/frontend/src/constants/constants.ts | 1 + src/frontend/src/types/components/index.ts | 31 +++ .../tests/core/unit/sliderComponent.spec.ts | 166 ++++++++++++ 13 files changed, 589 insertions(+) create mode 100644 src/frontend/src/components/sliderComponent/index.tsx create mode 100644 src/frontend/src/components/sliderComponent/utils/get-min-max-value.ts create mode 100644 src/frontend/src/components/ui/slider.tsx create mode 100644 src/frontend/tests/core/unit/sliderComponent.spec.ts diff --git a/src/backend/base/langflow/inputs/__init__.py b/src/backend/base/langflow/inputs/__init__.py index 043f539be..afed6b1fb 100644 --- a/src/backend/base/langflow/inputs/__init__.py +++ b/src/backend/base/langflow/inputs/__init__.py @@ -19,6 +19,7 @@ from .inputs import ( NestedDictInput, PromptInput, SecretStrInput, + SliderInput, StrInput, TableInput, ) @@ -46,4 +47,8 @@ __all__ = [ "SecretStrInput", "StrInput", "TableInput", + "Input", + "DefaultPromptField", + "LinkInput", + "SliderInput", ] diff --git a/src/backend/base/langflow/inputs/input_mixin.py b/src/backend/base/langflow/inputs/input_mixin.py index 896f2d5d2..23ba69756 100644 --- a/src/backend/base/langflow/inputs/input_mixin.py +++ b/src/backend/base/langflow/inputs/input_mixin.py @@ -29,6 +29,7 @@ class FieldTypes(str, Enum): OTHER = "other" TABLE = "table" LINK = "link" + SLIDER = "slider" SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)] @@ -167,6 +168,16 @@ class LinkMixin(BaseModel): """Text to be displayed in the link.""" +class SliderMixin(BaseModel): + min_label: str = Field(default="") + max_label: str = Field(default="") + min_label_icon: str = Field(default="") + max_label_icon: str = Field(default="") + slider_buttons: bool = Field(default=False) + slider_buttons_options: list[str] = Field(default=[]) + slider_input: bool = Field(default=False) + + 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 80805e68a..d00d5a257 100644 --- a/src/backend/base/langflow/inputs/inputs.py +++ b/src/backend/base/langflow/inputs/inputs.py @@ -23,6 +23,7 @@ from .input_mixin import ( MultilineMixin, RangeMixin, SerializableFieldTypes, + SliderMixin, TableMixin, ) @@ -470,6 +471,10 @@ class LinkInput(BaseInputMixin, LinkMixin): field_type: SerializableFieldTypes = FieldTypes.LINK +class SliderInput(BaseInputMixin, RangeMixin, SliderMixin): + field_type: SerializableFieldTypes = FieldTypes.SLIDER + + DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"] @@ -506,6 +511,7 @@ InputTypes = ( | MessageInput | TableInput | LinkInput + | SliderInput ) 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 fb4a6a43f..0109d24da 100644 --- a/src/backend/base/langflow/io/__init__.py +++ b/src/backend/base/langflow/io/__init__.py @@ -18,6 +18,7 @@ from langflow.inputs import ( NestedDictInput, PromptInput, SecretStrInput, + SliderInput, StrInput, TableInput, ) @@ -46,4 +47,7 @@ __all__ = [ "SecretStrInput", "StrInput", "TableInput", + "DefaultPromptField", + "LinkInput", + "SliderInput", ] diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 5e9289b71..c34afb2a4 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", @@ -3921,6 +3922,54 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz", + "integrity": "sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index c46cdb5dd..e59959c2c 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", diff --git a/src/frontend/src/components/parameterRenderComponent/index.tsx b/src/frontend/src/components/parameterRenderComponent/index.tsx index 401e2509f..aab5e44bf 100644 --- a/src/frontend/src/components/parameterRenderComponent/index.tsx +++ b/src/frontend/src/components/parameterRenderComponent/index.tsx @@ -1,7 +1,9 @@ import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value"; import { TEXT_FIELD_TYPES } from "@/constants/constants"; import { APIClassType, InputFieldType } from "@/types/api"; +import { Slider } from "@radix-ui/react-slider"; import { useMemo } from "react"; +import SliderComponent from "../sliderComponent"; import TableNodeComponent from "./components/TableNodeComponent"; import CodeAreaComponent from "./components/codeAreaComponent"; import DictComponent from "./components/dictComponent"; @@ -159,6 +161,22 @@ export function ParameterRenderComponent({ tableTitle={templateData?.display_name ?? "Table"} /> ); + case "slider": + return ( + + ); default: return ; } diff --git a/src/frontend/src/components/sliderComponent/index.tsx b/src/frontend/src/components/sliderComponent/index.tsx new file mode 100644 index 000000000..adf4e913e --- /dev/null +++ b/src/frontend/src/components/sliderComponent/index.tsx @@ -0,0 +1,254 @@ +import { Case } from "@/shared/components/caseComponent"; +import { useDarkStore } from "@/stores/darkStore"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import clsx from "clsx"; +import { useEffect } from "react"; +import { SliderComponentType } from "../../types/components"; +import IconComponent from "../genericIconComponent"; +import { InputProps } from "../parameterRenderComponent/types"; +import { getMinOrMaxValue } from "./utils/get-min-max-value"; + +const THRESHOLDS = [0.25, 0.5, 0.75, 1]; +const BACKGROUND_COLORS = ["#4f46e5", "#7c3aed", "#a21caf", "#c026d3"]; +const TEXT_COLORS = ["#fff", "#fff", "#fff", "#fff"]; +const PERCENTAGES = [0.125, 0.375, 0.625, 0.875]; + +const DARK_COLOR_BACKGROUND = "#09090b"; +const DARK_COLOR_TEXT = "#52525b"; +const LIGHT_COLOR_BACKGROUND = "#e4e4e7"; +const LIGHT_COLOR_TEXT = "#000"; + +const DEFAULT_SLIDER_BUTTONS_OPTIONS = [ + { id: 0, label: "Precise" }, + { id: 1, label: "Balanced" }, + { id: 2, label: "Creative" }, + { id: 3, label: "Wild" }, +]; + +const MIN_LABEL = "Precise"; +const MAX_LABEL = "Wild"; +const MIN_LABEL_ICON = "pencil-ruler"; +const MAX_LABEL_ICON = "palette"; + +type ColorType = "background" | "text"; + +export default function SliderComponent({ + value, + disabled, + rangeSpec, + editNode = false, + minLabel = MIN_LABEL, + maxLabel = MAX_LABEL, + minLabelIcon = MIN_LABEL_ICON, + maxLabelIcon = MAX_LABEL_ICON, + sliderButtons = false, + sliderButtonsOptions = DEFAULT_SLIDER_BUTTONS_OPTIONS, + sliderInput = false, + handleOnNewValue, +}: InputProps): JSX.Element { + const min = rangeSpec?.min ?? -2; + const max = rangeSpec?.max ?? 2; + + sliderButtonsOptions = + sliderButtons && sliderButtonsOptions && sliderButtonsOptions.length > 0 + ? sliderButtonsOptions + : DEFAULT_SLIDER_BUTTONS_OPTIONS; + + minLabelIcon = minLabelIcon || MIN_LABEL_ICON; + maxLabelIcon = maxLabelIcon || MAX_LABEL_ICON; + minLabel = minLabel || MIN_LABEL; + maxLabel = maxLabel || MAX_LABEL; + + const valueAsNumber = getMinOrMaxValue(Number(value), min, max); + const step = rangeSpec?.step ?? 0.1; + + useEffect(() => { + if (disabled && value !== "") { + handleOnNewValue({ value: "" }, { skipSnapshot: true }); + } + }, [disabled]); + + const handleChange = (newValue: number[]) => { + handleOnNewValue({ value: newValue[0] }); + }; + + const handleOptionClick = (option: number) => { + const selectedPercentage = PERCENTAGES[option]; + + if (selectedPercentage !== undefined) { + const calculatedValue = min + (max - min) * selectedPercentage; + handleOnNewValue({ value: calculatedValue }); + } + + return null; + }; + + const isDark = useDarkStore((state) => state.dark); + + const getNormalizedValue = ( + value: number, + min: number, + max: number, + ): number => { + return (value - min) / (max - min); + }; + + const getColor = ( + optionValue: number, + normalizedValue: number, + colorType: ColorType, + ): string => { + const colors = colorType === "background" ? BACKGROUND_COLORS : TEXT_COLORS; + const defaultColor = isDark + ? colorType === "background" + ? DARK_COLOR_BACKGROUND + : DARK_COLOR_TEXT + : colorType === "background" + ? LIGHT_COLOR_BACKGROUND + : LIGHT_COLOR_TEXT; + + if (normalizedValue <= THRESHOLDS[0] && optionValue === 0) { + return colors[0]; + } + + for (let i = 1; i < THRESHOLDS.length; i++) { + if ( + normalizedValue > THRESHOLDS[i - 1] && + normalizedValue <= THRESHOLDS[i] && + optionValue === i + ) { + return colors[i]; + } + } + + return defaultColor; + }; + + const getButtonBackground = (optionValue: number = 0): string => { + const normalizedValue = getNormalizedValue(valueAsNumber, min, max); + return getColor(optionValue, normalizedValue, "background"); + }; + + const getButtonTextColor = (optionValue: number = 0): string => { + const normalizedValue = getNormalizedValue(valueAsNumber, min, max); + return getColor(optionValue, normalizedValue, "text"); + }; + + return ( +
+ +
+ + {valueAsNumber.toFixed(2)} + +
+
+ +
+ + {valueAsNumber.toFixed(2)} + +
+
+ +
+ + + + + + + {sliderInput && ( + handleChange([parseFloat(e.target.value)])} + className={clsx( + "ml-2 h-10 w-16 rounded-md border px-2 py-1 text-sm arrow-hide", + isDark + ? "border-zinc-700 bg-zinc-800 text-white" + : "border-zinc-300 bg-white text-black", + )} + min={min} + max={max} + step={step} + disabled={disabled} + /> + )} +
+ + {sliderButtons && ( +
+
+ {sliderButtonsOptions?.map((option) => ( + + ))} +
+
+ )} + +
+
+
+
+ {maxLabel} +
+
+
+ ); +} diff --git a/src/frontend/src/components/sliderComponent/utils/get-min-max-value.ts b/src/frontend/src/components/sliderComponent/utils/get-min-max-value.ts new file mode 100644 index 000000000..3e9b48ee7 --- /dev/null +++ b/src/frontend/src/components/sliderComponent/utils/get-min-max-value.ts @@ -0,0 +1,16 @@ +export const getMinOrMaxValue = ( + valueAsNumber: number, + min: number, + max: number, +) => { + if (valueAsNumber < min) { + return min; + } + if (valueAsNumber > max) { + return max; + } + if (valueAsNumber >= min && valueAsNumber <= max) { + return valueAsNumber; + } + return min; +}; diff --git a/src/frontend/src/components/ui/slider.tsx b/src/frontend/src/components/ui/slider.tsx new file mode 100644 index 000000000..b123510d5 --- /dev/null +++ b/src/frontend/src/components/ui/slider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { cn } from "@/utils/utils"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 827b49e70..d6fc889af 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -651,6 +651,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([ "NestedDict", "table", "link", + "slider", ]); export const priorityFields = new Set(["code", "template"]); diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index f26257e3a..a9b5477f9 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -153,6 +153,37 @@ export type IntComponentType = { id?: string; }; +export type FloatComponentType = { + value: string; + disabled?: boolean; + onChange: ( + value: string | number, + dbValue?: boolean, + skipSnapshot?: boolean, + ) => void; + rangeSpec: RangeSpecType; + editNode?: boolean; + id?: string; +}; + +export type SliderComponentType = { + value: string; + disabled?: boolean; + rangeSpec: RangeSpecType; + editNode?: boolean; + id?: string; + minLabel?: string; + maxLabel?: string; + minLabelIcon?: string; + maxLabelIcon?: string; + sliderButtons?: boolean; + sliderButtonsOptions?: { + label: string; + id: number; + }[]; + sliderInput?: boolean; +}; + export type FilePreviewType = { loading: boolean; file: File; diff --git a/src/frontend/tests/core/unit/sliderComponent.spec.ts b/src/frontend/tests/core/unit/sliderComponent.spec.ts new file mode 100644 index 000000000..01ad32912 --- /dev/null +++ b/src/frontend/tests/core/unit/sliderComponent.spec.ts @@ -0,0 +1,166 @@ +import { expect, Page, test } from "@playwright/test"; +import uaParser from "ua-parser-js"; + +test("user should be able to use slider input", async ({ 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"; + + if (userAgentInfo.os.name.includes("Mac")) { + control = "Meta"; + } + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + await page.waitForSelector('[data-testid="extended-disclosure"]', { + timeout: 30000, + }); + await page.getByTestId("extended-disclosure").click(); + await page.getByPlaceholder("Search").click(); + await page.getByPlaceholder("Search").fill("ollama"); + + await page.waitForTimeout(1000); + + await page + .getByTestId("modelsOllama") + .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.getByTitle("zoom out").click(); + + await page.getByTestId("title-Ollama").click(); + await page.getByTestId("code-button-modal").click(); + + let cleanCode = await extractAndCleanCode(page); + + // Replace the import statement + cleanCode = cleanCode.replace("FloatInput(", "SliderInput("); + cleanCode = cleanCode.replace( + "from langflow.io import BoolInput, DictInput, DropdownInput, FloatInput, IntInput, StrInput", + "from langflow.io import BoolInput, DictInput, DropdownInput, FloatInput, IntInput, StrInput, SliderInput", + ); + + cleanCode = cleanCode.replace( + "value=0.2,", + "value=0.2, range_spec=RangeSpec(min=3, max=30, step=1), min_label='test', max_label='test2', min_label_icon='pencil-ruler', max_label_icon='palette', slider_buttons=False, slider_buttons_options=[], slider_input=False,", + ); + + await page.locator("textarea").last().press(`${control}+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 mutualValidation(page); + + await moveSlider(page, "right", false); + + await page.waitForTimeout(500); + + await page.getByTitle("zoom out").click(); + + await page.getByTestId("more-options-modal").click(); + await page.getByText("Advanced", { exact: true }).click(); + await expect( + page.getByTestId("default_slider_display_value_advanced"), + ).toHaveText("19.00"); + + await moveSlider(page, "left", true); + // Wait for any potential updates + await page.waitForTimeout(500); + + await expect( + page.getByTestId("default_slider_display_value_advanced"), + ).toHaveText("14.00"); + + await page.getByText("Close").last().click(); + + await expect(page.getByTestId("default_slider_display_value")).toHaveText( + "14.00", + ); +}); + +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; +} + +async function mutualValidation(page: Page) { + await expect(page.getByTestId("default_slider_display_value")).toHaveText( + "3.00", + ); + await expect(page.getByTestId("min_label")).toHaveText("test"); + await expect(page.getByTestId("max_label")).toHaveText("test2"); + await expect(page.getByTestId("icon-pencil-ruler")).toBeVisible(); + await expect(page.getByTestId("icon-palette")).toBeVisible(); +} +async function moveSlider( + page: Page, + side: "left" | "right", + advanced: boolean = false, +) { + const thumbSelector = `slider_thumb${advanced ? "_advanced" : ""}`; + const trackSelector = `slider_track${advanced ? "_advanced" : ""}`; + + await page.getByTestId(thumbSelector).click(); + + const trackBoundingBox = await page.getByTestId(trackSelector).boundingBox(); + + if (trackBoundingBox) { + const moveDistance = + trackBoundingBox.width * 0.1 * (side === "left" ? -1 : 1); + const centerX = trackBoundingBox.x + trackBoundingBox.width / 2; + const centerY = trackBoundingBox.y + trackBoundingBox.height / 2; + + await page.mouse.move(centerX + moveDistance, centerY); + await page.mouse.down(); + await page.mouse.up(); + } +}