mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36995 from BerriAI/litellm_/playground-config-select-25ad99
fix(ui): anchor chips-combobox popups to the field instead of the inner input
This commit is contained in:
commit
a4aab2e9f3
13 changed files with 160 additions and 38 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
|
|
@ -68,6 +69,8 @@ interface CachePageProps {
|
|||
// Helper function to deep-parse a JSON string if possible
|
||||
|
||||
const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole, userID, premiumUser }) => {
|
||||
const anchor1 = useComboboxAnchor();
|
||||
const anchor2 = useComboboxAnchor();
|
||||
const [selectedApiKeys, setSelectedApiKeys] = useState<string[]>([]);
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([]);
|
||||
|
||||
|
|
@ -194,7 +197,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
value={selectedApiKeys}
|
||||
onValueChange={(keys: string[]) => setSelectedApiKeys(keys)}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxChips render={<div ref={anchor1} />}>
|
||||
<ComboboxValue>
|
||||
{(keys: string[]) =>
|
||||
keys.map((key) => (
|
||||
|
|
@ -206,7 +209,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Virtual Keys" className="border-0 bg-transparent" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor1}>
|
||||
<ComboboxEmpty>No virtual keys found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(key: string) => (
|
||||
|
|
@ -224,7 +227,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
value={selectedModels}
|
||||
onValueChange={(models: string[]) => setSelectedModels(models)}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxChips render={<div ref={anchor2} />}>
|
||||
<ComboboxValue>
|
||||
{(models: string[]) =>
|
||||
models.map((model) => (
|
||||
|
|
@ -236,7 +239,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Models" className="border-0 bg-transparent" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor2}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -190,6 +191,7 @@ interface CustomCodeModalProps {
|
|||
}
|
||||
|
||||
const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onSuccess, accessToken, editData }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const isEditMode = !!editData;
|
||||
const [guardrailName, setGuardrailName] = useState("");
|
||||
const [mode, setMode] = useState<string[]>(["pre_call"]);
|
||||
|
|
@ -524,7 +526,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
|
|||
onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))}
|
||||
multiple
|
||||
>
|
||||
<ComboboxChips className="w-full">
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full">
|
||||
{selectedModeOptions.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
|
|
@ -535,7 +537,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
|
|||
placeholder={mode.length === 0 ? "Select modes" : undefined}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No matching modes</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: ModeOption) => (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -41,6 +42,7 @@ export interface CategoryFilterProps {
|
|||
}
|
||||
|
||||
export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const categoryNames = categories.map((cat) => cat.category);
|
||||
|
||||
return (
|
||||
|
|
@ -50,7 +52,7 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
|
|||
<span className="font-medium text-muted-foreground">Filter by category</span>
|
||||
</div>
|
||||
<Combobox items={categoryNames} value={selectedCategories} onValueChange={onChange} multiple>
|
||||
<ComboboxChips className="mb-4 w-full">
|
||||
<ComboboxChips render={<div ref={anchor} />} className="mb-4 w-full">
|
||||
{selectedCategories.map((category) => (
|
||||
<ComboboxChip key={category} aria-label={category}>
|
||||
{category}
|
||||
|
|
@ -61,7 +63,7 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
|
|||
placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No matching categories</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(category: string) => (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -91,6 +92,7 @@ const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => {
|
|||
};
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, userID, keys, premiumUser }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend");
|
||||
const currentDate = new Date();
|
||||
const [keySpendData, setKeySpendData] = useState<any[]>([]);
|
||||
|
|
@ -879,7 +881,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: TagOption) => option.label}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxChips render={<div ref={anchor} />}>
|
||||
<ComboboxValue>
|
||||
{(options: TagOption[]) =>
|
||||
options.map((option) => (
|
||||
|
|
@ -891,7 +893,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select tags" className="border-0 bg-transparent" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No tags found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: TagOption) => (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import EntityUsageExportModal from "./EntityUsageExportModal";
|
||||
import type { EntitySpendData, EntityType } from "./types";
|
||||
|
|
@ -51,6 +52,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
compactLayout = false,
|
||||
teams = [],
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
||||
const hasFilters = showFilters && filterOptions.length > 0;
|
||||
|
|
@ -58,7 +60,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
|
||||
|
||||
const filterList = (
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No options found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(value: string) => (
|
||||
|
|
@ -104,7 +106,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
value={selectedFilters}
|
||||
onValueChange={(next: string[]) => onFiltersChange?.(next)}
|
||||
>
|
||||
<ComboboxChips className="w-full">
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full">
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((value) => (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -120,6 +121,7 @@ const filterModels = (
|
|||
};
|
||||
|
||||
export const ModelSelect = (props: ModelSelectProps) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props;
|
||||
const { showAllProxyModelsOverride, includeSpecialOptions } = options || {};
|
||||
const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels();
|
||||
|
|
@ -234,7 +236,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
|||
isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value}
|
||||
itemToStringLabel={(option: ModelOption) => option.label}
|
||||
>
|
||||
<ComboboxChips data-testid={dataTestId} style={style} className="w-full">
|
||||
<ComboboxChips render={<div ref={anchor} />} data-testid={dataTestId} style={style} className="w-full">
|
||||
<ComboboxValue>
|
||||
{(selected: ModelOption[]) => (
|
||||
<>
|
||||
|
|
@ -260,7 +262,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
|||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(group: ModelOptionGroup) => (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -110,6 +111,7 @@ const DEFAULT_VALUES: SettingsValues = {
|
|||
};
|
||||
|
||||
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
|
@ -372,7 +374,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
|||
value={editedValues.team_member_permissions || []}
|
||||
onValueChange={(permissions: string[]) => update("team_member_permissions", permissions)}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxChips render={<div ref={anchor} />}>
|
||||
<ComboboxValue>
|
||||
{(permissions: string[]) =>
|
||||
permissions.map((permission) => (
|
||||
|
|
@ -388,7 +390,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
|||
aria-label="Team Member Permissions"
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxList>
|
||||
{(permission: string) => (
|
||||
<ComboboxItem key={permission} value={permission}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
|
@ -36,6 +37,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
pageSize = 20,
|
||||
placeholder = "Search teams by alias...",
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
onInputValueChange={debouncedSetSearch}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips className="w-full" aria-busy={isLoading}>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={isLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((teamId) => (
|
||||
|
|
@ -93,7 +95,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
/>
|
||||
{value.length > 0 && <ComboboxClear aria-label="Clear all teams" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : "No teams found"}
|
||||
</ComboboxEmpty>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -65,6 +66,7 @@ function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
|
|||
}
|
||||
|
||||
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
|
||||
const [agentHubData, setAgentHubData] = useState<AgentCard[] | null>(null);
|
||||
const [mcpHubData, setMcpHubData] = useState<MCPServerData[] | null>(null);
|
||||
|
|
@ -622,7 +624,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
value={selectedProviders}
|
||||
onValueChange={(values: string[]) => setSelectedProviders(values)}
|
||||
>
|
||||
<ComboboxChips className="min-h-8 w-full py-1 text-sm">
|
||||
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 w-full py-1 text-sm">
|
||||
<ComboboxValue>
|
||||
{(values: string[]) =>
|
||||
values.map((provider) => (
|
||||
|
|
@ -638,7 +640,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No providers found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(provider: string) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { fetchSearchTools } from "../networking";
|
||||
|
|
@ -31,6 +32,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
|||
placeholder = "Select search tools (optional)",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -67,7 +69,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
|||
onValueChange={(selected: string[]) => onChange(selected)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips className={cn("w-full", className)} aria-busy={loading}>
|
||||
<ComboboxChips render={<div ref={anchor} />} className={cn("w-full", className)} aria-busy={loading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((tool) => (
|
||||
|
|
@ -85,7 +87,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
|
|||
/>
|
||||
{value && value.length > 0 && <ComboboxClear aria-label="Clear all search tools" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{loading ? "Loading search tools…" : "No search tools found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(tool: string) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MultiSelect, type MultiSelectOption } from "./MultiSelect";
|
||||
|
||||
const OPTIONS: MultiSelectOption[] = [
|
||||
{ value: "vs-alpha", label: "alpha-kb (vs-alpha)" },
|
||||
{ value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" },
|
||||
];
|
||||
|
||||
const renderMultiSelect = (props: Partial<React.ComponentProps<typeof MultiSelect>> = {}) => {
|
||||
const onValueChange = vi.fn();
|
||||
render(<MultiSelect options={OPTIONS} onValueChange={onValueChange} placeholder="Select stores" {...props} />);
|
||||
return { onValueChange, input: screen.getByRole("combobox") };
|
||||
};
|
||||
|
||||
const openPopup = async (input: HTMLElement) => {
|
||||
await userEvent.click(input);
|
||||
return waitFor(() => {
|
||||
const popup = document.querySelector("[data-slot='combobox-content']");
|
||||
expect(popup).not.toBeNull();
|
||||
return popup as HTMLElement;
|
||||
});
|
||||
};
|
||||
|
||||
const stubWidth = (element: Element, width: number) =>
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
width,
|
||||
height: 32,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: 32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
const CHIPS_WIDTH = 300;
|
||||
const INPUT_WIDTH = 200;
|
||||
|
||||
describe("MultiSelect", () => {
|
||||
it("anchors the popup to the chips container rather than the inner input", async () => {
|
||||
const { input } = renderMultiSelect();
|
||||
const chips = input.closest("[data-slot='combobox-chips']");
|
||||
expect(chips).not.toBeNull();
|
||||
stubWidth(chips as Element, CHIPS_WIDTH);
|
||||
stubWidth(input, INPUT_WIDTH);
|
||||
|
||||
const popup = await openPopup(input);
|
||||
const positioner = popup.parentElement as HTMLElement;
|
||||
|
||||
expect(positioner.style.getPropertyValue("--anchor-width")).toBe(`${CHIPS_WIDTH}px`);
|
||||
});
|
||||
|
||||
it("reports the selected option values", async () => {
|
||||
const { onValueChange, input } = renderMultiSelect();
|
||||
|
||||
await openPopup(input);
|
||||
await userEvent.click(screen.getByText("alpha-kb (vs-alpha)"));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(["vs-alpha"]);
|
||||
});
|
||||
|
||||
it("renders a chip per selected value", () => {
|
||||
renderMultiSelect({ value: ["vs-alpha", "vs-beta"] });
|
||||
|
||||
expect(screen.getByLabelText("alpha-kb (vs-alpha)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels an unknown selected value with its raw id", () => {
|
||||
renderMultiSelect({ value: ["vs-deleted"] });
|
||||
|
||||
expect(screen.getByLabelText("vs-deleted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a typed value only when custom values are allowed", async () => {
|
||||
const { onValueChange, input } = renderMultiSelect({ allowCustomValues: true });
|
||||
|
||||
await userEvent.type(input, "vs-typed");
|
||||
await userEvent.click(await screen.findByText('Create "vs-typed"'));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(["vs-typed"]);
|
||||
});
|
||||
|
||||
it("does not offer a typed value when custom values are disallowed", async () => {
|
||||
const { input } = renderMultiSelect();
|
||||
|
||||
await userEvent.type(input, "vs-typed");
|
||||
|
||||
expect(screen.queryByText('Create "vs-typed"')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("No options found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export interface MultiSelectOption {
|
||||
|
|
@ -52,6 +53,7 @@ export function MultiSelect({
|
|||
allowCustomValues = false,
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
const safeOptions = options.filter(
|
||||
(option): option is MultiSelectOption =>
|
||||
|
|
@ -89,23 +91,25 @@ export function MultiSelect({
|
|||
filter={matchesQuery}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<ComboboxChips className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
|
||||
<ComboboxChips render={<div ref={anchor} />} className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
|
||||
<ComboboxValue>
|
||||
{(selected: MultiSelectOption[]) =>
|
||||
selected.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
{(selected: MultiSelectOption[]) => (
|
||||
<>
|
||||
{selected.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
placeholder={loading ? "Loading..." : placeholder}
|
||||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
placeholder={loading ? "Loading..." : placeholder}
|
||||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: MultiSelectOption) => (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -59,6 +60,7 @@ interface UserAgentActivityProps {
|
|||
}
|
||||
|
||||
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, userRole, dateValue, onDateChange }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
// Maximum number of categories to show in charts to prevent color palette overflow
|
||||
const MAX_CATEGORIES = 10;
|
||||
|
||||
|
|
@ -385,7 +387,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
|
|||
value={selectedTags}
|
||||
onValueChange={(next: string[]) => setSelectedTags(next)}
|
||||
>
|
||||
<ComboboxChips className="w-full" aria-busy={tagsLoading}>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={tagsLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((tag) => (
|
||||
|
|
@ -402,7 +404,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
|
|||
/>
|
||||
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No user agents found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(tag: string) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue