Skip to content

Commit

Permalink
enhance: combobox with nested groups & use for default alias dropdown (
Browse files Browse the repository at this point in the history
…#1013)

* enhance: combobox with nested groups & use for default alias dropdown

lint fixes

* move sort & suggested label out of ComboBox
  • Loading branch information
ivyjeong13 authored Dec 26, 2024
1 parent d3c0777 commit 432d39a
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 167 deletions.
26 changes: 2 additions & 24 deletions ui/admin/app/components/agent/AgentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import useSWR from "swr";
import { z } from "zod";

import { Model, ModelUsage, filterModelsByActive } from "~/lib/model/models";
import { ModelUsage } from "~/lib/model/models";
import { ModelApiService } from "~/lib/service/api/modelApiService";

import { TypographyH4 } from "~/components/Typography";
Expand All @@ -15,6 +15,7 @@ import {
ControlledCustomInput,
ControlledInput,
} from "~/components/form/controlledInputs";
import { getModelOptionsByModelProvider } from "~/components/model/DefaultModelAliasForm";
import { Form } from "~/components/ui/form";

const formSchema = z.object({
Expand Down Expand Up @@ -134,27 +135,4 @@ export function AgentForm({ agent, onSubmit, onChange }: AgentFormProps) {
</form>
</Form>
);

function getModelOptionsByModelProvider(models: Model[]) {
const byModelProviderGroups = filterModelsByActive(models).reduce(
(acc, model) => {
acc[model.modelProvider] = acc[model.modelProvider] || [];
acc[model.modelProvider].push(model);
return acc;
},
{} as Record<string, Model[]>
);

return Object.entries(byModelProviderGroups).map(
([modelProvider, models]) => {
const sorted = models.sort((a, b) =>
(a.name ?? "").localeCompare(b.name ?? "")
);
return {
heading: modelProvider,
value: sorted,
};
}
);
}
}
166 changes: 109 additions & 57 deletions ui/admin/app/components/composed/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,25 @@ type BaseOption = {

type GroupedOption<T extends BaseOption> = {
heading: string;
value: T[];
value: (T | GroupedOption<T>)[];
};

type ComboBoxProps<T extends BaseOption> = {
allowClear?: boolean;
clearLabel?: ReactNode;
emptyLabel?: ReactNode;
onChange: (option: T | null) => void;
options: T[] | GroupedOption<T>[];
options: (T | GroupedOption<T>)[];
placeholder?: string;
renderOption?: (option: T) => ReactNode;
value?: T | null;
};

export function ComboBox<T extends BaseOption>({
disabled,
placeholder,
value,
renderOption,
...props
}: {
disabled?: boolean;
Expand All @@ -50,10 +53,15 @@ export function ComboBox<T extends BaseOption>({

if (!isMobile) {
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover modal={true} open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{renderButtonContent()}</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<ComboBoxList setOpen={setOpen} value={value} {...props} />
<ComboBoxList
setOpen={setOpen}
renderOption={renderOption}
value={value}
{...props}
/>
</PopoverContent>
</Popover>
);
Expand All @@ -64,7 +72,12 @@ export function ComboBox<T extends BaseOption>({
<DrawerTrigger asChild>{renderButtonContent()}</DrawerTrigger>
<DrawerContent>
<div className="mt-4 border-t">
<ComboBoxList setOpen={setOpen} value={value} {...props} />
<ComboBoxList
setOpen={setOpen}
renderOption={renderOption}
value={value}
{...props}
/>
</div>
</DrawerContent>
</Drawer>
Expand All @@ -82,7 +95,9 @@ export function ComboBox<T extends BaseOption>({
}}
>
<span className="text-ellipsis overflow-hidden">
{value ? value.name : placeholder}
{renderOption && value
? renderOption(value)
: (value?.name ?? placeholder)}
</span>
</Button>
);
Expand All @@ -94,16 +109,69 @@ function ComboBoxList<T extends BaseOption>({
clearLabel,
onChange,
options,
placeholder = "Filter...",
setOpen,
renderOption,
value,
placeholder = "Filter...",
emptyLabel = "No results found.",
}: { setOpen: (open: boolean) => void } & ComboBoxProps<T>) {
const isGrouped = options.every((option) => "heading" in option);
const [filteredOptions, setFilteredOptions] =
useState<typeof options>(options);

const filterOptions = (
items: (T | GroupedOption<T>)[],
searchValue: string
): (T | GroupedOption<T>)[] =>
items.reduce(
(acc, option) => {
const isSingleValueMatch =
"name" in option &&
option.name
?.toLowerCase()
.includes(searchValue.toLowerCase());
const isGroupHeadingMatch =
"heading" in option &&
option.heading
.toLowerCase()
.includes(searchValue.toLowerCase());

if (isGroupHeadingMatch || isSingleValueMatch) {
return [...acc, option];
}

if ("heading" in option) {
const matches = filterOptions(option.value, searchValue);
return matches.length > 0
? [
...acc,
{
...option,
value: matches,
},
]
: acc;
}

return acc;
},
[] as (T | GroupedOption<T>)[]
);

const handleValueChange = (value: string) => {
setFilteredOptions(filterOptions(options, value));
};

return (
<Command>
<CommandInput placeholder={placeholder} />
<Command
shouldFilter={false}
className="w-[var(--radix-popper-anchor-width)]"
>
<CommandInput
placeholder={placeholder}
onValueChange={handleValueChange}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{emptyLabel}</CommandEmpty>
{allowClear && (
<CommandGroup>
<CommandItem
Expand All @@ -116,61 +184,45 @@ function ComboBoxList<T extends BaseOption>({
</CommandItem>
</CommandGroup>
)}
{isGrouped
? renderGroupedOptions(options)
: renderUngroupedOptions(options)}
{filteredOptions.map((option) =>
"heading" in option
? renderGroupedOption(option)
: renderUngroupedOption(option)
)}
</CommandList>
</Command>
);

function renderGroupedOptions(items: GroupedOption<T>[]) {
return items.map((group) => (
function renderGroupedOption(group: GroupedOption<T>) {
return (
<CommandGroup key={group.heading} heading={group.heading}>
{group.value.map((option) => (
<CommandItem
key={option.id}
value={option.name}
onSelect={(name) => {
const match =
group.value.find((opt) => opt.name === name) ||
null;
onChange(match);
setOpen(false);
}}
className="justify-between"
>
{option.name || option.id}{" "}
{value?.id === option.id && (
<CheckIcon className="w-4 h-4" />
)}
</CommandItem>
))}
{group.value.map((option) =>
"heading" in option
? renderGroupedOption(option)
: renderUngroupedOption(option)
)}
</CommandGroup>
));
);
}

function renderUngroupedOptions(items: T[]) {
function renderUngroupedOption(option: T) {
return (
<CommandGroup>
{items.map((option) => (
<CommandItem
key={option.id}
value={option.name}
onSelect={(name) => {
const match =
items.find((opt) => opt.name === name) || null;
onChange(match);
setOpen(false);
}}
className="justify-between"
>
{option.name || option.id}{" "}
{value?.id === option.id && (
<CheckIcon className="w-4 h-4" />
)}
</CommandItem>
))}
</CommandGroup>
<CommandItem
key={option.id}
value={option.name}
onSelect={() => {
onChange(option);
setOpen(false);
}}
className="justify-between"
>
<span>
{renderOption
? renderOption(option)
: (option.name ?? option.id)}
</span>
{value?.id === option.id && <CheckIcon className="w-4 h-4" />}
</CommandItem>
);
}
}
Loading

0 comments on commit 432d39a

Please sign in to comment.