fix: multiselect dropdown not visible (#3044)

* Fixed refresh button and refresh parameter

* Refactored Multiselect component and implemented custom values on it

* Fixed default values

* Made Backend support combobox on Dropdown and Multiselect
This commit is contained in:
Lucas Oliveira 2024-07-29 18:04:27 -03:00 • committed by GitHub
commit 7fa3d33d8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 261 additions and 325 deletions

View file

@ -135,17 +135,14 @@ class RangeMixin(BaseModel):
class DropDownMixin(BaseModel): class DropDownMixin(BaseModel):
options: Optional[list[str]] = None options: Optional[list[str]] = None
"""List of options for the field. Only used when is_list=True. Default is an empty list.""" """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): class MultilineMixin(BaseModel):
multiline: CoalesceBool = True 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): class TableMixin(BaseModel):
table_schema: Optional[TableSchema | list[Column]] = None table_schema: Optional[TableSchema | list[Column]] = None

View file

@ -11,7 +11,6 @@ from .input_mixin import (
BaseInputMixin, BaseInputMixin,
DatabaseLoadMixin, DatabaseLoadMixin,
DropDownMixin, DropDownMixin,
ComboboxMixin,
FieldTypes, FieldTypes,
FileMixin, FileMixin,
InputTraceMixin, InputTraceMixin,
@ -307,7 +306,7 @@ class DictInput(BaseInputMixin, ListableInputMixin, InputTraceMixin):
value: Optional[dict] = {} value: Optional[dict] = {}
class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin, ComboboxMixin): class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin):
""" """
Represents a dropdown input field. Represents a dropdown input field.
@ -316,7 +315,7 @@ class DropdownInput(BaseInputMixin, DropDownMixin, MetadataTraceMixin, ComboboxM
Attributes: Attributes:
field_type (Optional[SerializableFieldTypes]): The field type of the input. Defaults to FieldTypes.TEXT. 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. Default is None.
""" """
@ -341,6 +340,18 @@ class MultiselectInput(BaseInputMixin, ListableInputMixin, DropDownMixin, Metada
field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT
options: list[str] = Field(default_factory=list) options: list[str] = Field(default_factory=list)
is_list: bool = Field(default=True, serialization_alias="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): class FileInput(BaseInputMixin, ListableInputMixin, FileMixin, MetadataTraceMixin):

View file

@ -1,24 +1,16 @@
"use client"; import { PopoverAnchor } from "@radix-ui/react-popover";
import Fuse from "fuse.js";
import { VariantProps, cva } from "class-variance-authority"; import { useEffect, useRef, useState } from "react";
import isEqual from "lodash.isequal"; import { MultiselectComponentType } from "../../types/components";
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 { cn } from "../../utils/utils";
import { Badge } from "../ui/badge"; import { default as ForwardedIconComponent } from "../genericIconComponent";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
CommandSeparator,
} from "../ui/command"; } from "../ui/command";
import { import {
Popover, Popover,
@ -26,306 +18,213 @@ import {
PopoverContentWithoutPortal, PopoverContentWithoutPortal,
PopoverTrigger, PopoverTrigger,
} from "../ui/popover"; } from "../ui/popover";
import { Separator } from "../ui/separator";
const MultiselectBadgeWrapper = ({ export default function MultiselectComponent({
disabled,
isLoading,
value, value,
variant, options: defaultOptions,
className, combobox,
onDelete, onSelect,
}: {
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;
values?: T[];
}
export const Multiselect = forwardRef<
HTMLButtonElement,
MultiselectProps<MultiselectValue>
>(
(
{
options = [],
onValueChange,
variant,
placeholder = "Select options",
asChild = false,
className,
editNode = false, editNode = false,
values, id = "",
...props children,
}, }: MultiselectComponentType): JSX.Element {
ref, const [open, setOpen] = useState(children ? true : false);
) => {
// 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<MultiselectValue[]>( const refButton = useRef<HTMLButtonElement>(null);
value || [],
const PopoverContentDropdown =
children || editNode ? PopoverContent : PopoverContentWithoutPortal;
const [customValues, setCustomValues] = useState<string[]>([]);
const [searchValue, setSearchValue] = useState("");
const [filteredOptions, setFilteredOptions] = useState(defaultOptions);
const [onlySelected, setOnlySelected] = useState(false);
const [options, setOptions] = useState<string[]>(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<HTMLButtonElement>(ref);
useEffect(() => { useEffect(() => {
if (!!value && value?.length > 0 && !isEqual(selectedValues, value)) { searchRoleByTerm(searchValue);
setSelectedValues(value); }, [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("");
} }
}, [value, selectedValues]); }, [open]);
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 ( return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> <>
{Object.keys(options ?? [])?.length > 0 ? (
<>
<Popover open={open} onOpenChange={children ? () => {} : setOpen}>
{children ? (
<PopoverAnchor>{children}</PopoverAnchor>
) : (
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
ref={combinedRef} disabled={disabled}
{...props}
onClick={handleTogglePopover}
variant="primary" variant="primary"
size="xs" size="xs"
role="combobox" role="combobox"
ref={refButton}
aria-expanded={open}
data-testid={`${id ?? ""}`}
className={cn( className={cn(
editNode editNode
? "multiselect-component-outline" ? "dropdown-component-outline"
: "multiselect-component-false-outline", : "dropdown-component-false-outline",
"w-full justify-between font-normal", "w-full justify-between font-normal",
editNode ? "input-edit-node" : "py-2", editNode ? "input-edit-node" : "py-2",
className,
)} )}
> >
{selectedValues?.length > 0 ? ( <span
<div className="flex w-full items-center justify-between"> className="truncate"
<div className="flex flex-wrap items-center"> data-testid={`value-dropdown-` + id}
{selectedValues?.map((selectedValue) => { >
return ( {value &&
<MultiselectBadgeWrapper value.length > 0 &&
value={selectedValue} options.find((option) => value.includes(option))
onDelete={toggleOption} ? value.join(", ")
variant={variant} : "Choose an option..."}
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> </span>
<ChevronDown className="mx-2 h-4 cursor-pointer text-muted-foreground hover:text-accent-foreground" />
</div> <ForwardedIconComponent
)} name="ChevronsUpDown"
className="ml-2 h-4 w-4 shrink-0 opacity-50"
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContentMultiselect
id="multiselect-content"
side="bottom"
className={cn(
`nocopy nowheel nopan nodelete nodrag noundo w-full p-0`,
)} )}
style={{ <PopoverContentDropdown
minWidth: popoverContentMultiselectMinWidth, onOpenAutoFocus={(event) => {
maxWidth: popoverContentMultiselectMinWidth, event.preventDefault();
}} }}
align="start" side="bottom"
onEscapeKeyDown={() => setIsPopoverOpen(false)} avoidCollisions={!!children}
className="noflow nowheel nopan nodelete nodrag p-0"
style={
children
? {}
: { minWidth: refButton?.current?.clientWidth ?? "200px" }
}
> >
<Command> <Command>
<CommandInput <div className="flex items-center border-b px-3">
placeholder="Search" <ForwardedIconComponent
onKeyDown={handleInputKeyDown} name="search"
className="h-9" className="mr-2 h-4 w-4 shrink-0 opacity-50"
/> />
<CommandList> <input
<CommandEmpty>No results found.</CommandEmpty> onChange={(event) => {
<CommandGroup> setSearchValue(event.target.value);
{value?.map((option) => { searchRoleByTerm(event.target.value);
const isSelected = !!selectedValues.find( }}
(sv) => sv.value === option.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"
return ( />
<CommandItem <Button
key={option.value} unstyled
onSelect={() => toggleOption({ value: option })} className="ml-2"
className="cursor-pointer" onClick={() => setOnlySelected((old) => !old)}
> >
<div <ForwardedIconComponent
className="h-4 w-4"
name={onlySelected ? "CheckCheck" : "Check"}
/>
</Button>
</div>
<CommandList className="overflow-y-scroll">
<CommandEmpty>No values found.</CommandEmpty>
<CommandGroup defaultChecked={false}>
{filteredOptions?.map((option, id) => (
<CommandItem
key={id}
value={option}
onSelect={(currentValue) => {
if (value.includes(currentValue)) {
onSelect(value.filter((v) => v !== currentValue));
} else {
onSelect([...value, currentValue]);
}
}}
className="items-center truncate"
data-testid={`${option}-${id ?? ""}-option`}
>
{customValues.includes(option) ||
searchValue === option ? (
<span className="text-muted-foreground">
Text:&nbsp;
</span>
) : (
<></>
)}
{option}
<ForwardedIconComponent
name="Check"
className={cn( className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", "ml-auto h-4 w-4 text-primary",
isSelected value.includes(option)
? "bg-primary text-primary-foreground" ? "opacity-100"
: "opacity-50 [&_svg]:invisible", : "opacity-0",
)} )}
>
<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> </CommandItem>
</div> ))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContentMultiselect> </PopoverContentDropdown>
</Popover> </Popover>
</>
) : (
<>
{(!isLoading && (
<div>
<span className="text-sm italic">
No parameters are available for display.
</span>
</div>
)) || (
<div>
<span className="text-sm italic">Loading...</span>
</div>
)}
</>
)}
</>
); );
}, }
);
Multiselect.displayName = "Multiselect";

View file

@ -8,6 +8,7 @@ export function RefreshParameterComponent({
templateData, templateData,
disabled, disabled,
nodeClass, nodeClass,
editNode,
handleNodeClass, handleNodeClass,
nodeId, nodeId,
name, name,
@ -29,12 +30,13 @@ export function RefreshParameterComponent({
); );
return ( return (
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<div className="w-full">{children}</div> {children}
{templateData.refresh_button && ( {templateData.refresh_button && (
<div className="w-1/6"> <div className="shrink-0 flex-col">
<RefreshButton <RefreshButton
isLoading={postTemplateValue.isPending} isLoading={postTemplateValue.isPending}
disabled={disabled} disabled={disabled}
editNode={editNode}
button_text={templateData.refresh_button_text} button_text={templateData.refresh_button_text}
handleUpdateValues={handleRefreshButtonPress} handleUpdateValues={handleRefreshButtonPress}
id={"refresh-button-" + name} id={"refresh-button-" + name}

View file

@ -3,7 +3,7 @@ import { InputFieldType } from "@/types/api";
import Dropdown from "../../../dropdownComponent"; import Dropdown from "../../../dropdownComponent";
import InputGlobalComponent from "../../../inputGlobalComponent"; import InputGlobalComponent from "../../../inputGlobalComponent";
import InputListComponent from "../../../inputListComponent"; import InputListComponent from "../../../inputListComponent";
import { Multiselect } from "../../../multiselectComponent"; import MultiselectComponent from "../../../multiselectComponent";
import TextAreaComponent from "../../../textAreaComponent"; import TextAreaComponent from "../../../textAreaComponent";
export function StrRenderComponent({ export function StrRenderComponent({
@ -33,7 +33,7 @@ export function StrRenderComponent({
componentName={name ?? undefined} componentName={name ?? undefined}
editNode={editNode} editNode={editNode}
disabled={disabled} disabled={disabled}
value={!value || value === "" ? [""] : value} value={value || [""]}
onChange={onChange} onChange={onChange}
id={`inputlist_${id}`} id={`inputlist_${id}`}
/> />
@ -67,13 +67,22 @@ export function StrRenderComponent({
if (!!templateData.options && !!templateData?.list) { if (!!templateData.options && !!templateData?.list) {
return ( return (
<Multiselect <MultiselectComponent
editNode={editNode} editNode={editNode}
disabled={disabled} disabled={disabled}
options={templateData.options || []} options={
values={[value ?? "Choose an option"]} (Array.isArray(templateData.options)
? templateData.options
: [templateData.options]) || []
}
combobox={templateData.combobox}
value={
(Array.isArray(templateData.value)
? templateData.value
: [templateData.value]) || []
}
id={`multiselect_${id}`} id={`multiselect_${id}`}
onValueChange={onChange} onSelect={onChange}
/> />
); );
} }
@ -85,7 +94,7 @@ export function StrRenderComponent({
options={templateData.options} options={templateData.options}
onSelect={onChange} onSelect={onChange}
combobox={templateData.combobox} combobox={templateData.combobox}
value={value ?? "Choose an option"} value={value || ""}
id={`dropdown_${id}`} id={`dropdown_${id}`}
/> />
); );

View file

@ -49,6 +49,7 @@ export function ParameterRenderComponent({
templateData={templateData} templateData={templateData}
disabled={disabled} disabled={disabled}
nodeId={nodeId} nodeId={nodeId}
editNode={editNode}
nodeClass={nodeClass} nodeClass={nodeClass}
handleNodeClass={handleNodeClass} handleNodeClass={handleNodeClass}
name={name} name={name}

View file

@ -24,7 +24,11 @@ function RefreshButton({
handleUpdateValues(); 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 // icon class name should take into account the disabled state and the loading state
const disabledIconTextClass = disabled ? "text-muted-foreground" : ""; const disabledIconTextClass = disabled ? "text-muted-foreground" : "";
@ -37,7 +41,7 @@ function RefreshButton({
className={classNames} className={classNames}
onClick={handleClick} onClick={handleClick}
id={id} id={id}
size={editNode ? "sm" : "default"} size={"icon"}
loading={isLoading} loading={isLoading}
> >
{button_text && <span className="mr-1">{button_text}</span>} {button_text && <span className="mr-1">{button_text}</span>}

View file

@ -61,6 +61,17 @@ export type DropDownComponentType = {
id?: string; id?: string;
children?: ReactNode; 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 = { export type ParameterComponentType = {
selected?: boolean; selected?: boolean;
data: NodeDataType; data: NodeDataType;

View file

@ -16,6 +16,7 @@ import {
Braces, Braces,
BrainCircuit, BrainCircuit,
Check, Check,
CheckCheck,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
@ -390,6 +391,7 @@ export const nodeIconsLucide: iconsType = {
IFixitLoader: IFixIcon, IFixitLoader: IFixIcon,
CrewAI: CrewAiIcon, CrewAI: CrewAiIcon,
Meta: MetaIcon, Meta: MetaIcon,
CheckCheck,
Midjorney: MidjourneyIcon, Midjorney: MidjourneyIcon,
MongoDBAtlasVectorSearch: MongoDBIcon, MongoDBAtlasVectorSearch: MongoDBIcon,
MongoDB: MongoDBIcon, MongoDB: MongoDBIcon,