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):
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

View file

@ -11,7 +11,6 @@ from .input_mixin import (
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):

View file

@ -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<MultiselectValue>["variant"];
className: MultiselectProps<MultiselectValue>["className"];
onDelete: ({ value }: { value: MultiselectValue }) => void;
}) => {
const badgeRef = useRef<HTMLDivElement>(null);
options: defaultOptions,
combobox,
onSelect,
editNode = false,
id = "",
children,
}: MultiselectComponentType): JSX.Element {
const [open, setOpen] = useState(children ? true : false);
const handleDelete = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
event.stopPropagation();
onDelete({ value });
const refButton = useRef<HTMLButtonElement>(null);
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,
);
};
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 (
<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,
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<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>
<>
{Object.keys(options ?? [])?.length > 0 ? (
<>
<Popover open={open} onOpenChange={children ? () => {} : setOpen}>
{children ? (
<PopoverAnchor>{children}</PopoverAnchor>
) : (
<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>
{value?.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"
/>
</>
<PopoverTrigger asChild>
<Button
disabled={disabled}
variant="primary"
size="xs"
role="combobox"
ref={refButton}
aria-expanded={open}
data-testid={`${id ?? ""}`}
className={cn(
editNode
? "dropdown-component-outline"
: "dropdown-component-false-outline",
"w-full justify-between font-normal",
editNode ? "input-edit-node" : "py-2",
)}
<CommandSeparator />
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 cursor-pointer justify-center"
>
<span
className="truncate"
data-testid={`value-dropdown-` + id}
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContentMultiselect>
</Popover>
);
},
);
{value &&
value.length > 0 &&
options.find((option) => value.includes(option))
? value.join(", ")
: "Choose an option..."}
</span>
Multiselect.displayName = "Multiselect";
<ForwardedIconComponent
name="ChevronsUpDown"
className="ml-2 h-4 w-4 shrink-0 opacity-50"
/>
</Button>
</PopoverTrigger>
)}
<PopoverContentDropdown
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
side="bottom"
avoidCollisions={!!children}
className="noflow nowheel nopan nodelete nodrag p-0"
style={
children
? {}
: { minWidth: refButton?.current?.clientWidth ?? "200px" }
}
>
<Command>
<div className="flex items-center border-b px-3">
<ForwardedIconComponent
name="search"
className="mr-2 h-4 w-4 shrink-0 opacity-50"
/>
<input
onChange={(event) => {
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"
/>
<Button
unstyled
className="ml-2"
onClick={() => setOnlySelected((old) => !old)}
>
<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(
"ml-auto h-4 w-4 text-primary",
value.includes(option)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContentDropdown>
</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>
)}
</>
)}
</>
);
}

View file

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

View file

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

View file

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

View file

@ -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 && <span className="mr-1">{button_text}</span>}

View file

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

View file

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