diff --git a/src/backend/base/langflow/inputs/input_mixin.py b/src/backend/base/langflow/inputs/input_mixin.py index 9b9c49db6c5..fe7f54f5674 100644 --- a/src/backend/base/langflow/inputs/input_mixin.py +++ b/src/backend/base/langflow/inputs/input_mixin.py @@ -135,17 +135,14 @@ class RangeMixin(BaseModel): class DropDownMixin(BaseModel): options: Optional[list[str]] = None """List of options for the field. Only used when is_list=True. Default is an empty list.""" + combobox: CoalesceBool = False + """Variable that defines if the user can insert custom values in the dropdown.""" class MultilineMixin(BaseModel): multiline: CoalesceBool = True -class ComboboxMixin(BaseModel): - combobox: CoalesceBool = False - """Variable that defines if the user can insert custom values in the dropdown.""" - - class TableMixin(BaseModel): table_schema: Optional[TableSchema | list[Column]] = None diff --git a/src/backend/base/langflow/inputs/inputs.py b/src/backend/base/langflow/inputs/inputs.py index aa328d4931d..c8023775f24 100644 --- a/src/backend/base/langflow/inputs/inputs.py +++ b/src/backend/base/langflow/inputs/inputs.py @@ -11,7 +11,6 @@ BaseInputMixin, DatabaseLoadMixin, DropDownMixin, - ComboboxMixin, FieldTypes, FileMixin, InputTraceMixin, @@ -307,7 +306,7 @@ class DictInput(BaseInputMixin, ListableInputMixin, InputTraceMixin): value: Optional[dict] = {} -class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin, ComboboxMixin): +class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin): """ Represents a dropdown input field. @@ -316,7 +315,7 @@ class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin, ComboboxM Attributes: field_type (Optional[SerializableFieldTypes]): The field type of the input. Defaults to FieldTypes.TEXT. - options (Optional[Union[list[str], Callable]]): List of options for the field. Only used when is_list=True. + options (Optional[Union[list[str], Callable]]): List of options for the field. Default is None. """ @@ -341,6 +340,18 @@ class MultiselectInput(BaseInputMixin, ListableInputMixin, DropDownMixin, Metada field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT options: list[str] = Field(default_factory=list) is_list: bool = Field(default=True, serialization_alias="list") + combobox: CoalesceBool = False + + @field_validator("value") + @classmethod + def validate_value(cls, v: Any, _info): + # Check if value is a list of dicts + if not isinstance(v, list): + raise ValueError(f"MultiselectInput value must be a list. Value: '{v}'") + for item in v: + if not isinstance(item, str): + raise ValueError(f"MultiselectInput value must be a list of strings. Item: '{item}' is not a string") + return v class FileInput(BaseInputMixin, ListableInputMixin, FileMixin, MetadataTraceMixin): diff --git a/src/frontend/src/components/multiselectComponent/index.tsx b/src/frontend/src/components/multiselectComponent/index.tsx index e294c4edd69..ba8e825d93d 100644 --- a/src/frontend/src/components/multiselectComponent/index.tsx +++ b/src/frontend/src/components/multiselectComponent/index.tsx @@ -1,24 +1,16 @@ -"use client"; - -import { VariantProps, cva } from "class-variance-authority"; -import isEqual from "lodash.isequal"; -import { CheckIcon, ChevronDown, XCircle, XIcon } from "lucide-react"; -import { forwardRef, useEffect, useRef, useState } from "react"; - -import useMergeRefs, { - isRefObject, -} from "../../CustomNodes/hooks/use-merge-refs"; +import { PopoverAnchor } from "@radix-ui/react-popover"; +import Fuse from "fuse.js"; +import { useEffect, useRef, useState } from "react"; +import { MultiselectComponentType } from "../../types/components"; import { cn } from "../../utils/utils"; -import { Badge } from "../ui/badge"; +import { default as ForwardedIconComponent } from "../genericIconComponent"; import { Button } from "../ui/button"; import { Command, CommandEmpty, CommandGroup, - CommandInput, CommandItem, CommandList, - CommandSeparator, } from "../ui/command"; import { Popover, @@ -26,306 +18,213 @@ import { PopoverContentWithoutPortal, PopoverTrigger, } from "../ui/popover"; -import { Separator } from "../ui/separator"; -const MultiselectBadgeWrapper = ({ +export default function MultiselectComponent({ + disabled, + isLoading, value, - variant, - className, - onDelete, -}: { - value: MultiselectValue; - variant: MultiselectProps["variant"]; - className: MultiselectProps["className"]; - onDelete: ({ value }: { value: MultiselectValue }) => void; -}) => { - const badgeRef = useRef(null); - - const handleDelete = ( - event: React.MouseEvent, - ) => { - event.stopPropagation(); - onDelete({ value }); - }; - - return ( - -
- {value?.label} -
-
-
- -
- - ); -}; - -const multiselectVariants = cva("m-1 ", { - variants: { - variant: { - default: - "border-foreground/10 text-foreground bg-card hover:bg-card/80 whitespace-normal", - secondary: - "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80 whitespace-normal", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 whitespace-normal", - inverted: "inverted", - }, - }, - defaultVariants: { - variant: "default", - }, -}); - -type MultiselectValue = { - label: string; - value: string; -}; - -interface MultiselectProps - extends Omit, "value">, - VariantProps { - options: T[]; - onValueChange: (value: T[]) => void; - placeholder?: string; - asChild?: boolean; - className?: string; - editNode?: boolean; - values?: T[]; -} - -export const Multiselect = forwardRef< - HTMLButtonElement, - MultiselectProps ->( - ( - { - options = [], - onValueChange, - variant, - placeholder = "Select options", - asChild = false, - className, - editNode = false, - values, - ...props - }, - ref, - ) => { - // if elements in values are strings, create the multiselectValue object - // otherwise, use the values as is - const value = Array.isArray(values) - ? values?.map((v) => (typeof v === "string" ? { label: v, value: v } : v)) - : []; - - const [selectedValues, setSelectedValues] = useState( - value || [], + options: defaultOptions, + combobox, + onSelect, + editNode = false, + id = "", + children, +}: MultiselectComponentType): JSX.Element { + const [open, setOpen] = useState(children ? true : false); + + const refButton = useRef(null); + + const PopoverContentDropdown = + children || editNode ? PopoverContent : PopoverContentWithoutPortal; + + const [customValues, setCustomValues] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [filteredOptions, setFilteredOptions] = useState(defaultOptions); + const [onlySelected, setOnlySelected] = useState(false); + + const [options, setOptions] = useState(defaultOptions); + + const fuseOptions = new Fuse(options, { keys: ["name", "value"] }); + const fuseValues = new Fuse(value, { keys: ["name", "value"] }); + + const searchRoleByTerm = async (v: string) => { + const fuse = onlySelected ? fuseValues : fuseOptions; + const searchValues = fuse.search(v); + let filtered: string[] = searchValues.map((search) => search.item); + if (!filtered.includes(v) && combobox) filtered = [v, ...filtered]; + setFilteredOptions( + v + ? filtered + : onlySelected + ? options.filter((x) => value.includes(x)) + : options, ); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const combinedRef = useMergeRefs(ref); - useEffect(() => { - if (!!value && value?.length > 0 && !isEqual(selectedValues, value)) { - setSelectedValues(value); - } - }, [value, selectedValues]); - - const handleInputKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (event.key === "Enter") { - setIsPopoverOpen(true); - } else if (event.key === "Backspace" && !event.currentTarget.value) { - const newSelectedValues = [...selectedValues]; - newSelectedValues.pop(); - setSelectedValues(newSelectedValues); - onValueChange(newSelectedValues); - } - }; - - const toggleOption = ({ value }: { value: MultiselectValue }) => { - const newSelectedValues = !!selectedValues.find( - (v) => v.value === value.value, - ) - ? selectedValues.filter((v) => v.value !== value.value) - : [...selectedValues, value]; - setSelectedValues(newSelectedValues); - onValueChange(newSelectedValues); - }; - - const handleClear = () => { - setSelectedValues([]); - onValueChange([]); - }; - - const handleTogglePopover = () => { - setIsPopoverOpen((prev) => !prev); - }; - - const PopoverContentMultiselect = editNode - ? PopoverContent - : PopoverContentWithoutPortal; + }; - const popoverContentMultiselectMinWidth = isRefObject(combinedRef) - ? `${combinedRef?.current?.clientWidth}px` - : "200px"; + useEffect(() => { + searchRoleByTerm(searchValue); + }, [onlySelected]); + + useEffect(() => { + searchRoleByTerm(searchValue); + }, [options]); + + useEffect(() => { + setCustomValues(value.filter((v) => !defaultOptions.includes(v)) ?? []); + setOptions([ + ...value.filter((v) => !defaultOptions.includes(v)), + ...defaultOptions, + ]); + }, [value]); + + useEffect(() => { + if (open) { + setOnlySelected(false); + setSearchValue(""); + searchRoleByTerm(""); + } + }, [open]); - return ( - - - + )} - > - {selectedValues?.length > 0 ? ( -
-
- {selectedValues?.map((selectedValue) => { - return ( - - ); - })} -
-
- { - event.stopPropagation(); - handleClear(); - }} + { + event.preventDefault(); + }} + side="bottom" + avoidCollisions={!!children} + className="noflow nowheel nopan nodelete nodrag p-0" + style={ + children + ? {} + : { minWidth: refButton?.current?.clientWidth ?? "200px" } + } + > + +
+ - { + setSearchValue(event.target.value); + searchRoleByTerm(event.target.value); + }} + placeholder="Search options..." + className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" /> - +
-
- ) : ( -
- - {placeholder} - - -
- )} - - - setIsPopoverOpen(false)} - > - - - - No results found. - - {value?.map((option) => { - const isSelected = !!selectedValues.find( - (sv) => sv.value === option.value, - ); - return ( - toggleOption({ value: option })} - className="cursor-pointer" - > -
- -
- {option.label} -
- ); - })} -
- - -
- {selectedValues?.length > 0 && ( - <> + + + No values found. + + {filteredOptions?.map((option, id) => ( { + if (value.includes(currentValue)) { + onSelect(value.filter((v) => v !== currentValue)); + } else { + onSelect([...value, currentValue]); + } + }} + className="items-center truncate" + data-testid={`${option}-${id ?? ""}-option`} > - Clear + {customValues.includes(option) || + searchValue === option ? ( + + Text:  + + ) : ( + <> + )} + {option} + - - - )} - - setIsPopoverOpen(false)} - className="flex-1 cursor-pointer justify-center" - > - Close - -
-
-
-
-
- - ); - }, -); - -Multiselect.displayName = "Multiselect"; + ))} + + + + + + + ) : ( + <> + {(!isLoading && ( +
+ + No parameters are available for display. + +
+ )) || ( +
+ Loading... +
+ )} + + )} + + ); +} diff --git a/src/frontend/src/components/parameterRenderComponent/component/refreshParameterComponent/index.tsx b/src/frontend/src/components/parameterRenderComponent/component/refreshParameterComponent/index.tsx index 23139f5776c..8289143f69b 100644 --- a/src/frontend/src/components/parameterRenderComponent/component/refreshParameterComponent/index.tsx +++ b/src/frontend/src/components/parameterRenderComponent/component/refreshParameterComponent/index.tsx @@ -8,6 +8,7 @@ export function RefreshParameterComponent({ templateData, disabled, nodeClass, + editNode, handleNodeClass, nodeId, name, @@ -29,12 +30,13 @@ export function RefreshParameterComponent({ ); return (
-
{children}
+ {children} {templateData.refresh_button && ( -
+
@@ -67,13 +67,22 @@ export function StrRenderComponent({ if (!!templateData.options && !!templateData?.list) { return ( - ); } @@ -85,7 +94,7 @@ export function StrRenderComponent({ options={templateData.options} onSelect={onChange} combobox={templateData.combobox} - value={value ?? "Choose an option"} + value={value || ""} id={`dropdown_${id}`} /> ); diff --git a/src/frontend/src/components/parameterRenderComponent/index.tsx b/src/frontend/src/components/parameterRenderComponent/index.tsx index eea2f42c915..1a1679fb2f4 100644 --- a/src/frontend/src/components/parameterRenderComponent/index.tsx +++ b/src/frontend/src/components/parameterRenderComponent/index.tsx @@ -49,6 +49,7 @@ export function ParameterRenderComponent({ templateData={templateData} disabled={disabled} nodeId={nodeId} + editNode={editNode} nodeClass={nodeClass} handleNodeClass={handleNodeClass} name={name} diff --git a/src/frontend/src/components/ui/refreshButton.tsx b/src/frontend/src/components/ui/refreshButton.tsx index 1efb365db21..2e87a9b6f70 100644 --- a/src/frontend/src/components/ui/refreshButton.tsx +++ b/src/frontend/src/components/ui/refreshButton.tsx @@ -24,7 +24,11 @@ function RefreshButton({ handleUpdateValues(); }; - const classNames = cn(className, disabled ? "cursor-not-allowed" : ""); + const classNames = cn( + className, + disabled ? "cursor-not-allowed" : "", + !editNode ? "py-2.5 px-3" : "px-2 py-1", + ); // icon class name should take into account the disabled state and the loading state const disabledIconTextClass = disabled ? "text-muted-foreground" : ""; @@ -37,7 +41,7 @@ function RefreshButton({ className={classNames} onClick={handleClick} id={id} - size={editNode ? "sm" : "default"} + size={"icon"} loading={isLoading} > {button_text && {button_text}} diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index e8a412f0300..7840c5fa30a 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -61,6 +61,17 @@ export type DropDownComponentType = { id?: string; children?: ReactNode; }; +export type MultiselectComponentType = { + disabled?: boolean; + isLoading?: boolean; + value: string[]; + combobox?: boolean; + options: string[]; + onSelect: (value: string[]) => void; + editNode?: boolean; + id?: string; + children?: ReactNode; +}; export type ParameterComponentType = { selected?: boolean; data: NodeDataType; diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index f5389f3b984..3bde56e9aef 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -16,6 +16,7 @@ import { Braces, BrainCircuit, Check, + CheckCheck, CheckCircle2, ChevronDown, ChevronLeft, @@ -390,6 +391,7 @@ export const nodeIconsLucide: iconsType = { IFixitLoader: IFixIcon, CrewAI: CrewAiIcon, Meta: MetaIcon, + CheckCheck, Midjorney: MidjourneyIcon, MongoDBAtlasVectorSearch: MongoDBIcon, MongoDB: MongoDBIcon,