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 <gabriel@langflow.org>
This commit is contained in:
596050 2024-07-11 17:14:10 +01:00 committed by GitHub
commit 180e475cc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 488 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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({
)}
</div>
</Case>
<Case
condition={
type === "str" &&
!!data.node?.template[name]?.options &&
!!data.node?.template[name]?.list
}
>
<div className="mt-2 flex w-full items-center">
<Multiselect
disabled={disabled}
options={data?.node?.template?.[name]?.options || []}
value={data?.node?.template?.[name]?.value || []}
id={"multiselect-" + name}
onValueChange={handleOnNewValue}
/>
</div>
</Case>
<Case condition={left === true && type === "code"}>
<div className="mt-2 w-full">

View file

@ -0,0 +1,35 @@
import { MutableRefObject, useEffect, useRef } from "react";
type Ref<T> = MutableRefObject<T | null> | ((instance: T | null) => void);
export function isRefCallback<T>(
ref: Ref<T>,
): ref is (instance: T | null) => void {
return typeof ref === "function";
}
export function isRefObject<T>(ref: Ref<T>): ref is MutableRefObject<T> {
return !isRefCallback(ref);
}
function useMergeRefs<T>(
...refs: (Ref<T> | null | undefined)[]
): Ref<T | null> {
const targetRef: Ref<T | null> = useRef<T | null>(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;

View file

@ -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<MultiselectValue>["variant"];
className: MultiselectProps<MultiselectValue>["className"];
onDelete: ({ value }: { value: MultiselectValue }) => void;
}) => {
const badgeRef = useRef<HTMLDivElement>(null);
const handleDelete = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
event.stopPropagation();
onDelete({ value });
};
return (
<Badge
className={cn(
"overflow-hidden rounded-sm p-0 font-normal",
multiselectVariants({ variant, className }),
)}
>
<div id="content" className="p-1 pr-0" ref={badgeRef}>
{value?.label}
</div>
<div id="spacer" className="p-1" />
<div
id="delete"
className="flex items-center justify-center px-1 hover:bg-red-300/80"
style={{
minHeight: `${badgeRef?.current?.clientHeight}px`,
}}
onClick={handleDelete}
>
<XCircle
className="h-4 min-h-4 w-4 min-w-4 cursor-pointer"
style={{
minHeight: `${badgeRef?.current?.clientHeight}px`,
}}
/>
</div>
</Badge>
);
};
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<T>
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value">,
VariantProps<typeof multiselectVariants> {
options: T[];
onValueChange: (value: T[]) => void;
placeholder?: string;
asChild?: boolean;
className?: string;
editNode?: boolean;
value?: T[];
}
export const Multiselect = forwardRef<
HTMLButtonElement,
MultiselectProps<MultiselectValue>
>(
(
{
options = [],
onValueChange,
variant,
placeholder = "Select options",
asChild = false,
className,
editNode = false,
value,
...props
},
ref,
) => {
const [selectedValues, setSelectedValues] = useState<MultiselectValue[]>(
value || [],
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const combinedRef = useMergeRefs<HTMLButtonElement>(ref);
useEffect(() => {
if (!!value && value?.length > 0 && !isEqual(selectedValues, value)) {
setSelectedValues(value);
}
}, [value, selectedValues]);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
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 (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={combinedRef}
{...props}
onClick={handleTogglePopover}
variant="primary"
size="xs"
role="combobox"
className={cn(
editNode
? "multiselect-component-outline"
: "multiselect-component-false-outline",
"w-full justify-between font-normal",
editNode ? "input-edit-node" : "py-2",
className,
)}
>
{selectedValues?.length > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex flex-wrap items-center">
{selectedValues?.map((selectedValue) => {
return (
<MultiselectBadgeWrapper
value={selectedValue}
onDelete={toggleOption}
variant={variant}
className={className}
key={selectedValue.value}
/>
);
})}
</div>
<div className="flex items-center justify-between">
<XIcon
className="mx-2 cursor-pointer rounded-md text-sm text-muted-foreground ring-offset-background transition-colors hover:bg-secondary-foreground/5 hover:text-accent-foreground hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-background/10"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
<ChevronDown className="mx-2 h-4 cursor-pointer rounded-md text-sm text-muted-foreground ring-offset-background transition-colors hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</div>
</div>
) : (
<div className="mx-auto flex w-full items-center justify-between">
<span className="mx-3 text-sm text-muted-foreground">
{placeholder}
</span>
<ChevronDown className="mx-2 h-4 cursor-pointer text-muted-foreground hover:text-accent-foreground" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContentMultiselect
id="multiselect-content"
side="bottom"
className={cn(
`nocopy nowheel nopan nodelete nodrag noundo w-full p-0`,
)}
style={{
minWidth: popoverContentMultiselectMinWidth,
maxWidth: popoverContentMultiselectMinWidth,
}}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search"
onKeyDown={handleInputKeyDown}
className="h-9"
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options?.map((option) => {
const isSelected = !!selectedValues.find(
(sv) => sv.value === option.value,
);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption({ value: option })}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues?.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
className="flex-1 cursor-pointer justify-center"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
</>
)}
<CommandSeparator />
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 cursor-pointer justify-center"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContentMultiselect>
</Popover>
);
},
);
Multiselect.displayName = "Multiselect";

View file

@ -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 ? (
<InputListComponent
componentName={templateData.key ?? undefined}
editNode={true}
disabled={disabled}
value={!templateValue || templateValue === "" ? [""] : templateValue}
onChange={(value: string[]) => {
handleOnNewValue(value, templateData.key);
}}
/>
) : templateData.multiline ? (
<TextAreaComponent
id={"textarea-edit-" + templateData.name}
data-testid={"textarea-edit-" + templateData.name}
disabled={disabled}
editNode={true}
value={templateValue ?? ""}
onChange={(value: string | string[]) => {
handleOnNewValue(value, templateData.key);
}}
/>
) : (
<InputGlobalComponent
disabled={disabled}
editNode={true}
onChange={(value, dbValue, snapshot) =>
handleOnNewValue(value, templateData.key, dbValue)
}
name={templateData.key}
data={templateData}
/>
);
}
if (!!templateData.options && !!templateData?.list) {
return (
<Multiselect
editNode={true}
disabled={disabled}
options={templateData.options || []}
value={templateValue ?? "Choose an option"}
id={"multiselect-" + templateData.name}
onValueChange={(value) => handleOnNewValue(value, templateData.key)}
/>
);
}
if (!!templateData.options) {
return (
<Dropdown
editNode={true}
options={templateData.options}
onSelect={(value) => handleOnNewValue(value, templateData.key)}
value={templateValue ?? "Choose an option"}
id={"dropdown-edit-" + templateData.name}
/>
);
}
}

View file

@ -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 ? (
<InputListComponent
componentName={templateData.key ?? undefined}
editNode={true}
disabled={disabled}
value={
!templateValue || templateValue === "" ? [""] : templateValue
}
onChange={(value: string[]) => {
handleOnNewValue(value, templateData.key);
}}
/>
) : templateData.multiline ? (
<TextAreaComponent
id={"textarea-edit-" + templateData.name}
data-testid={"textarea-edit-" + templateData.name}
disabled={disabled}
editNode={true}
value={templateValue ?? ""}
onChange={(value: string | string[]) => {
handleOnNewValue(value, templateData.key);
}}
/>
) : (
<InputGlobalComponent
disabled={disabled}
editNode={true}
onChange={(value, dbValue, snapshot) =>
handleOnNewValue(value, templateData.key, dbValue)
}
name={templateData.key}
data={templateData}
/>
);
} else {
return (
<Dropdown
editNode={true}
options={templateData.options}
onSelect={(value) => handleOnNewValue(value, templateData.key)}
value={templateValue ?? "Choose an option"}
id={"dropdown-edit-" + templateData.name}
/>
);
}
return renderStrType({
templateData,
templateValue,
disabled,
handleOnNewValue,
});
case "NestedDict":
return (

View file

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