feat: add new input type - SliderComponent + tests (#4144)
This commit is contained in:
parent
ab77ca752b
commit
51b3909d60
13 changed files with 589 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ from .inputs import (
|
||||||
NestedDictInput,
|
NestedDictInput,
|
||||||
PromptInput,
|
PromptInput,
|
||||||
SecretStrInput,
|
SecretStrInput,
|
||||||
|
SliderInput,
|
||||||
StrInput,
|
StrInput,
|
||||||
TableInput,
|
TableInput,
|
||||||
)
|
)
|
||||||
|
|
@ -46,4 +47,8 @@ __all__ = [
|
||||||
"SecretStrInput",
|
"SecretStrInput",
|
||||||
"StrInput",
|
"StrInput",
|
||||||
"TableInput",
|
"TableInput",
|
||||||
|
"Input",
|
||||||
|
"DefaultPromptField",
|
||||||
|
"LinkInput",
|
||||||
|
"SliderInput",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class FieldTypes(str, Enum):
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
TABLE = "table"
|
TABLE = "table"
|
||||||
LINK = "link"
|
LINK = "link"
|
||||||
|
SLIDER = "slider"
|
||||||
|
|
||||||
|
|
||||||
SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value, return_type=str)]
|
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."""
|
"""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):
|
class TableMixin(BaseModel):
|
||||||
table_schema: TableSchema | list[Column] | None = None
|
table_schema: TableSchema | list[Column] | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from .input_mixin import (
|
||||||
MultilineMixin,
|
MultilineMixin,
|
||||||
RangeMixin,
|
RangeMixin,
|
||||||
SerializableFieldTypes,
|
SerializableFieldTypes,
|
||||||
|
SliderMixin,
|
||||||
TableMixin,
|
TableMixin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -470,6 +471,10 @@ class LinkInput(BaseInputMixin, LinkMixin):
|
||||||
field_type: SerializableFieldTypes = FieldTypes.LINK
|
field_type: SerializableFieldTypes = FieldTypes.LINK
|
||||||
|
|
||||||
|
|
||||||
|
class SliderInput(BaseInputMixin, RangeMixin, SliderMixin):
|
||||||
|
field_type: SerializableFieldTypes = FieldTypes.SLIDER
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"]
|
DEFAULT_PROMPT_INTUT_TYPES = ["Message", "Text"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -506,6 +511,7 @@ InputTypes = (
|
||||||
| MessageInput
|
| MessageInput
|
||||||
| TableInput
|
| TableInput
|
||||||
| LinkInput
|
| LinkInput
|
||||||
|
| SliderInput
|
||||||
)
|
)
|
||||||
|
|
||||||
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)}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from langflow.inputs import (
|
||||||
NestedDictInput,
|
NestedDictInput,
|
||||||
PromptInput,
|
PromptInput,
|
||||||
SecretStrInput,
|
SecretStrInput,
|
||||||
|
SliderInput,
|
||||||
StrInput,
|
StrInput,
|
||||||
TableInput,
|
TableInput,
|
||||||
)
|
)
|
||||||
|
|
@ -46,4 +47,7 @@ __all__ = [
|
||||||
"SecretStrInput",
|
"SecretStrInput",
|
||||||
"StrInput",
|
"StrInput",
|
||||||
"TableInput",
|
"TableInput",
|
||||||
|
"DefaultPromptField",
|
||||||
|
"LinkInput",
|
||||||
|
"SliderInput",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
49
src/frontend/package-lock.json
generated
49
src/frontend/package-lock.json
generated
|
|
@ -24,6 +24,7 @@
|
||||||
"@radix-ui/react-progress": "^1.0.3",
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
|
"@radix-ui/react-slider": "^1.2.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@radix-ui/react-progress": "^1.0.3",
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
|
"@radix-ui/react-slider": "^1.2.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
||||||
import { TEXT_FIELD_TYPES } from "@/constants/constants";
|
import { TEXT_FIELD_TYPES } from "@/constants/constants";
|
||||||
import { APIClassType, InputFieldType } from "@/types/api";
|
import { APIClassType, InputFieldType } from "@/types/api";
|
||||||
|
import { Slider } from "@radix-ui/react-slider";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import SliderComponent from "../sliderComponent";
|
||||||
import TableNodeComponent from "./components/TableNodeComponent";
|
import TableNodeComponent from "./components/TableNodeComponent";
|
||||||
import CodeAreaComponent from "./components/codeAreaComponent";
|
import CodeAreaComponent from "./components/codeAreaComponent";
|
||||||
import DictComponent from "./components/dictComponent";
|
import DictComponent from "./components/dictComponent";
|
||||||
|
|
@ -159,6 +161,22 @@ export function ParameterRenderComponent({
|
||||||
tableTitle={templateData?.display_name ?? "Table"}
|
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:
|
default:
|
||||||
return <EmptyParameterComponent {...baseInputProps} />;
|
return <EmptyParameterComponent {...baseInputProps} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
254
src/frontend/src/components/sliderComponent/index.tsx
Normal file
254
src/frontend/src/components/sliderComponent/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
27
src/frontend/src/components/ui/slider.tsx
Normal file
27
src/frontend/src/components/ui/slider.tsx
Normal 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 };
|
||||||
|
|
@ -651,6 +651,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
||||||
"NestedDict",
|
"NestedDict",
|
||||||
"table",
|
"table",
|
||||||
"link",
|
"link",
|
||||||
|
"slider",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const priorityFields = new Set(["code", "template"]);
|
export const priorityFields = new Set(["code", "template"]);
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,37 @@ export type IntComponentType = {
|
||||||
id?: string;
|
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 = {
|
export type FilePreviewType = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
file: File;
|
file: File;
|
||||||
|
|
|
||||||
166
src/frontend/tests/core/unit/sliderComponent.spec.ts
Normal file
166
src/frontend/tests/core/unit/sliderComponent.spec.ts
Normal 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(/"/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue