From 180e475cc3007c003d44b3756ae3b244ef42a279 Mon Sep 17 00:00:00 2001 From: 596050 Date: Thu, 11 Jul 2024 17:14:10 +0100 Subject: [PATCH] Frontend Feature: Multiselect ui-component and merge refs hook (#2405) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Gabriel Luiz Freitas Almeida --- src/backend/base/langflow/custom/utils.py | 2 +- src/backend/base/langflow/inputs/__init__.py | 2 + src/backend/base/langflow/inputs/inputs.py | 18 + src/backend/base/langflow/io/__init__.py | 2 + .../components/parameterComponent/index.tsx | 18 + .../src/CustomNodes/hooks/use-merge-refs.tsx | 35 ++ .../components/multiselectComponent/index.tsx | 326 ++++++++++++++++++ .../tableNodeCellRender/cellTypeStr.tsx | 72 ++++ .../components/tableNodeCellRender/index.tsx | 53 +-- src/frontend/src/style/applies.css | 7 + 10 files changed, 488 insertions(+), 47 deletions(-) create mode 100644 src/frontend/src/CustomNodes/hooks/use-merge-refs.tsx create mode 100644 src/frontend/src/components/multiselectComponent/index.tsx create mode 100644 src/frontend/src/components/tableComponent/components/tableNodeCellRender/cellTypeStr.tsx diff --git a/src/backend/base/langflow/custom/utils.py b/src/backend/base/langflow/custom/utils.py index c5f2d9f3c..1cb7d085c 100644 --- a/src/backend/base/langflow/custom/utils.py +++ b/src/backend/base/langflow/custom/utils.py @@ -171,7 +171,7 @@ def add_new_custom_field( field_config["load_from_db"] = True field_config["input_types"] = ["Text"] - # If options is a list, then it's a dropdown + # If options is a list, then it's a dropdown or multiselect # If options is None, then it's a list of strings is_list = isinstance(field_config.get("options"), list) field_config["is_list"] = is_list or field_config.get("list", False) or field_contains_list diff --git a/src/backend/base/langflow/inputs/__init__.py b/src/backend/base/langflow/inputs/__init__.py index ffa3f4486..49ef05a69 100644 --- a/src/backend/base/langflow/inputs/__init__.py +++ b/src/backend/base/langflow/inputs/__init__.py @@ -3,6 +3,7 @@ from .inputs import ( DataInput, DictInput, DropdownInput, + MultiselectInput, FileInput, FloatInput, HandleInput, @@ -21,6 +22,7 @@ __all__ = [ "DataInput", "DictInput", "DropdownInput", + "MultiselectInput", "FileInput", "FloatInput", "HandleInput", diff --git a/src/backend/base/langflow/inputs/inputs.py b/src/backend/base/langflow/inputs/inputs.py index 4578660dc..0658570ed 100644 --- a/src/backend/base/langflow/inputs/inputs.py +++ b/src/backend/base/langflow/inputs/inputs.py @@ -289,6 +289,23 @@ class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin): options: list[str] = Field(default_factory=list) +class MultiselectInput(BaseInputMixin, ListableInputMixin, DropDownMixin, MetadataTraceMixin): + """ + Represents a multiselect input field. + + This class represents a multiselect input field and provides functionality for handling multiselect values. + It inherits from the `BaseInputMixin`, `ListableInputMixin` and `DropDownMixin` classes. + + 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. + Default is None. + """ + + field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT + options: list[str] = Field(default_factory=list) + + class FileInput(BaseInputMixin, ListableInputMixin, FileMixin, MetadataTraceMixin): """ Represents a file field. @@ -308,6 +325,7 @@ InputTypes = Union[ DataInput, DictInput, DropdownInput, + MultiselectInput, FileInput, FloatInput, HandleInput, diff --git a/src/backend/base/langflow/io/__init__.py b/src/backend/base/langflow/io/__init__.py index 63e859925..af9d3c8da 100644 --- a/src/backend/base/langflow/io/__init__.py +++ b/src/backend/base/langflow/io/__init__.py @@ -3,6 +3,7 @@ from langflow.inputs import ( DataInput, DictInput, DropdownInput, + MultiselectInput, FileInput, FloatInput, HandleInput, @@ -22,6 +23,7 @@ __all__ = [ "DataInput", "DictInput", "DropdownInput", + "MultiselectInput", "FileInput", "FloatInput", "HandleInput", diff --git a/src/frontend/src/CustomNodes/GenericNode/components/parameterComponent/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/parameterComponent/index.tsx index 156d4984f..2b81b4d66 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/parameterComponent/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/parameterComponent/index.tsx @@ -12,6 +12,7 @@ import InputGlobalComponent from "../../../../components/inputGlobalComponent"; import InputListComponent from "../../../../components/inputListComponent"; import IntComponent from "../../../../components/intComponent"; import KeypairListComponent from "../../../../components/keypairListComponent"; +import { Multiselect } from "../../../../components/multiselectComponent"; import PromptAreaComponent from "../../../../components/promptComponent"; import ShadTooltip from "../../../../components/shadTooltipComponent"; import TextAreaComponent from "../../../../components/textAreaComponent"; @@ -558,6 +559,23 @@ export default function ParameterComponent({ )} + +
+ +
+
diff --git a/src/frontend/src/CustomNodes/hooks/use-merge-refs.tsx b/src/frontend/src/CustomNodes/hooks/use-merge-refs.tsx new file mode 100644 index 000000000..383fe5c01 --- /dev/null +++ b/src/frontend/src/CustomNodes/hooks/use-merge-refs.tsx @@ -0,0 +1,35 @@ +import { MutableRefObject, useEffect, useRef } from "react"; + +type Ref = MutableRefObject | ((instance: T | null) => void); + +export function isRefCallback( + ref: Ref, +): ref is (instance: T | null) => void { + return typeof ref === "function"; +} + +export function isRefObject(ref: Ref): ref is MutableRefObject { + return !isRefCallback(ref); +} + +function useMergeRefs( + ...refs: (Ref | null | undefined)[] +): Ref { + const targetRef: Ref = useRef(null); + + useEffect(() => { + refs.forEach((ref) => { + if (!ref) return; + + if (typeof ref === "function") { + ref(targetRef.current); + } else { + ref.current = targetRef.current as T; + } + }); + }, [refs]); + + return targetRef; +} + +export default useMergeRefs; diff --git a/src/frontend/src/components/multiselectComponent/index.tsx b/src/frontend/src/components/multiselectComponent/index.tsx new file mode 100644 index 000000000..6b6e7c468 --- /dev/null +++ b/src/frontend/src/components/multiselectComponent/index.tsx @@ -0,0 +1,326 @@ +"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 { cn } from "../../utils/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; +import { + Popover, + PopoverContent, + PopoverContentWithoutPortal, + PopoverTrigger, +} from "../ui/popover"; +import { Separator } from "../ui/separator"; + +const MultiselectBadgeWrapper = ({ + 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; + value?: T[]; +} + +export const Multiselect = forwardRef< + HTMLButtonElement, + MultiselectProps +>( + ( + { + options = [], + onValueChange, + variant, + placeholder = "Select options", + asChild = false, + className, + editNode = false, + value, + ...props + }, + ref, + ) => { + const [selectedValues, setSelectedValues] = useState( + value || [], + ); + 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"; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + No results found. + + {options?.map((option) => { + const isSelected = !!selectedValues.find( + (sv) => sv.value === option.value, + ); + return ( + toggleOption({ value: option })} + className="cursor-pointer" + > +
+ +
+ {option.label} +
+ ); + })} +
+ + +
+ {selectedValues?.length > 0 && ( + <> + + Clear + + + + )} + + setIsPopoverOpen(false)} + className="flex-1 cursor-pointer justify-center" + > + Close + +
+
+
+
+
+
+ ); + }, +); + +Multiselect.displayName = "Multiselect"; diff --git a/src/frontend/src/components/tableComponent/components/tableNodeCellRender/cellTypeStr.tsx b/src/frontend/src/components/tableComponent/components/tableNodeCellRender/cellTypeStr.tsx new file mode 100644 index 000000000..7b32ab232 --- /dev/null +++ b/src/frontend/src/components/tableComponent/components/tableNodeCellRender/cellTypeStr.tsx @@ -0,0 +1,72 @@ +import Dropdown from "../../../dropdownComponent"; +import InputGlobalComponent from "../../../inputGlobalComponent"; +import InputListComponent from "../../../inputListComponent"; +import { Multiselect } from "../../../multiselectComponent"; +import TextAreaComponent from "../../../textAreaComponent"; + +export function renderStrType({ + templateData, + templateValue, + disabled, + handleOnNewValue, +}) { + if (!templateData.options) { + return templateData?.list ? ( + { + handleOnNewValue(value, templateData.key); + }} + /> + ) : templateData.multiline ? ( + { + handleOnNewValue(value, templateData.key); + }} + /> + ) : ( + + handleOnNewValue(value, templateData.key, dbValue) + } + name={templateData.key} + data={templateData} + /> + ); + } + + if (!!templateData.options && !!templateData?.list) { + return ( + handleOnNewValue(value, templateData.key)} + /> + ); + } + + if (!!templateData.options) { + return ( + handleOnNewValue(value, templateData.key)} + value={templateValue ?? "Choose an option"} + id={"dropdown-edit-" + templateData.name} + /> + ); + } +} diff --git a/src/frontend/src/components/tableComponent/components/tableNodeCellRender/index.tsx b/src/frontend/src/components/tableComponent/components/tableNodeCellRender/index.tsx index 8482a0da4..83c1d1d25 100644 --- a/src/frontend/src/components/tableComponent/components/tableNodeCellRender/index.tsx +++ b/src/frontend/src/components/tableComponent/components/tableNodeCellRender/index.tsx @@ -22,6 +22,7 @@ import KeypairListComponent from "../../../keypairListComponent"; import PromptAreaComponent from "../../../promptComponent"; import TextAreaComponent from "../../../textAreaComponent"; import ToggleShadComponent from "../../../toggleShadComponent"; +import { renderStrType } from "./cellTypeStr"; export default function TableNodeCellRender({ node: { data }, @@ -75,52 +76,12 @@ export default function TableNodeCellRender({ function getCellType() { switch (templateData.type) { case "str": - if (!templateData.options) { - return templateData?.list ? ( - { - handleOnNewValue(value, templateData.key); - }} - /> - ) : templateData.multiline ? ( - { - handleOnNewValue(value, templateData.key); - }} - /> - ) : ( - - handleOnNewValue(value, templateData.key, dbValue) - } - name={templateData.key} - data={templateData} - /> - ); - } else { - return ( - handleOnNewValue(value, templateData.key)} - value={templateValue ?? "Choose an option"} - id={"dropdown-edit-" + templateData.name} - /> - ); - } + return renderStrType({ + templateData, + templateValue, + disabled, + handleOnNewValue, + }); case "NestedDict": return ( diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css index 23ebfe52b..cdf769915 100644 --- a/src/frontend/src/style/applies.css +++ b/src/frontend/src/style/applies.css @@ -546,6 +546,13 @@ @apply h-5 w-5 text-primary; } + .multiselect-component-outline { + @apply input-edit-node relative pr-8; + } + .multiselect-component-false-outline { + @apply primary-input py-2 pl-3 pr-10 text-left; + } + .edit-flow-arrangement { @apply flex justify-between; }