feat: add new input type - SliderComponent + tests (#4144)

This commit is contained in:
Cristhian Zanforlin Lousa 2024-10-16 11:00:27 -03:00 committed by GitHub
commit 51b3909d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 589 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<SliderComponent
{...baseInputProps}
value={templateValue}
rangeSpec={templateData.range_spec}
minLabel={templateData?.min_label}
maxLabel={templateData?.max_label}
minLabelIcon={templateData?.min_label_icon}
maxLabelIcon={templateData?.max_label_icon}
sliderButtons={templateData?.slider_buttons}
sliderButtonsOptions={templateData?.slider_buttons_options}
sliderInput={templateData?.slider_input}
id={`slider_${id}`}
/>
);
default:
return <EmptyParameterComponent {...baseInputProps} />;
}

View file

@ -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<string[] | number[], SliderComponentType>): 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 (
<div className="w-full rounded-lg pb-2">
<Case condition={!sliderButtons && !sliderInput}>
<div className="relative bottom-2 flex items-center justify-end">
<span
data-testid={`default_slider_display_value${editNode ? "_advanced" : ""}`}
className="font-mono text-sm"
>
{valueAsNumber.toFixed(2)}
</span>
</div>
</Case>
<Case condition={sliderButtons && !sliderInput}>
<div className="relative bottom-1 flex items-center pb-2">
<span
data-testid={`button_slider_display_value${editNode ? "_advanced" : ""}`}
className="font-mono text-2xl"
>
{valueAsNumber.toFixed(2)}
</span>
</div>
</Case>
<div className="flex cursor-default items-center justify-center">
<SliderPrimitive.Root
className="relative flex h-5 w-full touch-none select-none items-center"
value={[valueAsNumber]}
onValueChange={handleChange}
min={min}
max={max}
step={step}
disabled={disabled}
>
<SliderPrimitive.Track
data-testid={`slider_track${editNode ? "_advanced" : ""}`}
className={clsx(
"relative h-1 w-full grow rounded-full",
isDark ? "bg-zinc-800" : "bg-zinc-200",
)}
>
<SliderPrimitive.Range className="absolute h-full rounded-full bg-gradient-to-r from-indigo-600 to-pink-500" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-testid={`slider_thumb${editNode ? "_advanced" : ""}`}
className={clsx(
"block h-6 w-6 rounded-full border-2 border-muted bg-pink-500 shadow-lg",
)}
/>
</SliderPrimitive.Root>
{sliderInput && (
<input
data-testid={`slider_input_value${editNode ? "_advanced" : ""}`}
type="number"
value={valueAsNumber.toFixed(2)}
onChange={(e) => 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}
/>
)}
</div>
{sliderButtons && (
<div className="my-3">
<div
className={clsx(
"flex rounded-md",
isDark ? "bg-zinc-950" : "bg-zinc-200",
)}
>
{sliderButtonsOptions?.map((option) => (
<button
key={option.id}
onClick={() => handleOptionClick(option.id)}
style={{
background: getButtonBackground(option.id),
color: getButtonTextColor(option.id),
}}
className={clsx(
"h-9 flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-200",
)}
disabled={disabled}
>
{option.label}
</button>
))}
</div>
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-x-2 text-sm text-gray-500">
<div className="flex items-center">
<IconComponent
className="mr-1 h-4 w-4"
name={minLabelIcon}
aria-hidden="true"
/>
<span data-testid="min_label">{minLabel}</span>
</div>
<div className="flex items-center justify-end">
<span data-testid="max_label">{maxLabel}</span>
<IconComponent
className="ml-1 h-4 w-4"
name={maxLabelIcon}
aria-hidden="true"
/>
</div>
</div>
</div>
);
}

View file

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

View file

@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View file

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

View file

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

View file

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