refactor(ui): migrate shared primitives and common components off antd (#37521)

* refactor(ui): migrate shared primitives and common components off antd

Adds the success variant to the shared Alert plus success, warning and
info variants to Badge, introduces UtcDateTimeInput to replace antd's
DatePicker, and converts the common components and key/team helpers onto
the shadcn primitives.

* fix(ui): keep MultiSelect and budget input faithful to their antd behaviour

Restore the clear-all control MultiSelect lost, split comma-separated
custom entries into one value per token, and stop rounding the budget
input on every keystroke so a fractional amount survives typing.

* test(ui): drive the access group picker through the migrated MultiSelect

AccessGroupSelector no longer renders an antd Select, so the placeholder
is an input label rather than a text node and the popup inerts the page
until it closes.
This commit is contained in:
ryan-crabbe-berri 2026-08-19 17:25:09 -07:00 • committed by GitHub
parent 629d7683f2
commit fc8a6b2a8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 266 additions and 206 deletions

View file

@ -542,11 +542,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx": {
"no-restricted-imports": {
"count": 1
@ -647,9 +642,6 @@
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/index.tsx": {
@ -661,9 +653,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/static-components": {
"count": 4
}
@ -1475,11 +1464,6 @@
"count": 1
}
},
"src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx": {
"no-nested-ternary": {
"count": 1
@ -1506,11 +1490,6 @@
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx": {
"react-hooks/set-state-in-render": {
"count": 1
@ -1590,9 +1569,6 @@
"src/components/VirtualKeysPage/keyTableColumns.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/activity_metrics.tsx": {
@ -1603,11 +1579,6 @@
"count": 1
}
},
"src/components/add_model/AdaptiveRoutingConfig.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/AddModelForm.test.tsx": {
"no-restricted-imports": {
"count": 1
@ -1620,26 +1591,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/add_model/ClassificationMethodConfig.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/ComplexityRouterConfig.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/EscalationKeywords.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/KeywordTierRules.tsx": {
"no-restricted-imports": {
"count": 1
}
@ -1649,11 +1600,6 @@
"count": 1
}
},
"src/components/add_model/SemanticKeywordMatching.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/add_auto_router_tab.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1672,7 +1618,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"src/components/add_model/auto_router_connection_test.tsx": {
@ -2078,7 +2024,7 @@
},
"src/components/model_add/CredentialModal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/components/model_add/reuse_credentials.tsx": {

View file

@ -971,8 +971,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
// Should still render the data from the paginated fallback
expect(screen.getByText("1,500")).toBeInTheDocument();
// Should still render the data from the paginated fallback, which lands a render after the call
expect(await screen.findByText("1,500")).toBeInTheDocument();
});
it("should stop showing the previous range's paginated pages while a new range is in flight", async () => {

View file

@ -1,6 +1,7 @@
import React from "react";
import { Select, Skeleton } from "antd";
import { Users } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import { useAccessGroups, AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
export interface AccessGroupSelectorProps {
@ -12,8 +13,6 @@ export interface AccessGroupSelectorProps {
className?: string;
showLabel?: boolean;
labelText?: string;
/** Allow clearing the selection */
allowClear?: boolean;
}
/**
@ -32,7 +31,6 @@ const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
className,
showLabel = false,
labelText = "Access Group",
allowClear = true,
}) => {
const { data: accessGroups, isLoading, isError } = useAccessGroups();
@ -45,22 +43,16 @@ const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
<Users className="mr-2 size-4" /> {labelText}
</p>
)}
<Skeleton.Input active block style={{ height: 32, ...style }} />
<Skeleton className="h-8 w-full" style={style} />
</div>
);
}
// ── Build options ────────────────────────────────────────────────────────
const options = (accessGroups ?? []).map((group: AccessGroupResponse) => ({
label: (
<span>
<span className="font-medium">{group.access_group_name}</span>{" "}
<span className="text-xs text-muted-foreground">({group.access_group_id})</span>
</span>
),
const options: MultiSelectOption[] = (accessGroups ?? []).map((group: AccessGroupResponse) => ({
label: group.access_group_name,
value: group.access_group_id,
selectedLabel: group.access_group_name,
searchText: `${group.access_group_name} ${group.access_group_id}`,
description: group.access_group_id,
}));
// ── Render ───────────────────────────────────────────────────────────────
@ -71,30 +63,17 @@ const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
<Users className="mr-2 size-4" /> {labelText}
</p>
)}
<Select
mode="multiple"
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
allowClear={allowClear}
showSearch
style={{ width: "100%", ...style }}
className={`rounded-md ${className ?? ""}`}
notFoundContent={
isError ? <span className="text-destructive">Failed to load access groups</span> : "No access groups found"
}
filterOption={(input, option) => {
const searchText = options.find((opt) => opt.value === option?.value)?.searchText ?? "";
return searchText.toLowerCase().includes(input.toLowerCase());
}}
optionLabelProp="selectedLabel"
options={options.map((opt) => ({
label: opt.label,
value: opt.value,
selectedLabel: opt.selectedLabel,
}))}
/>
<div style={style}>
<MultiSelect
options={options}
value={value}
onValueChange={onChange ?? (() => {})}
placeholder={placeholder}
emptyText={isError ? "Failed to load access groups" : "No access groups found"}
disabled={disabled}
className={`w-full rounded-md ${className ?? ""}`}
/>
</div>
</div>
);
};

View file

@ -1,57 +1,33 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { Form } from "antd";
import React from "react";
import { RateLimitTypeFormItem } from "./RateLimitTypeFormItem";
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => <Form>{children}</Form>;
describe("RateLimitTypeFormItem", () => {
it("should render", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>,
);
renderWithProviders(<RateLimitTypeFormItem type="tpm" name="tpm_type" />);
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
});
it("should display TPM label for tpm type", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>,
);
renderWithProviders(<RateLimitTypeFormItem type="tpm" name="tpm_type" />);
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
});
it("should display RPM label for rpm type", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="rpm" name="rpm_type" />
</Wrapper>,
);
renderWithProviders(<RateLimitTypeFormItem type="rpm" name="rpm_type" />);
expect(screen.getByText(/RPM Rate Limit Type/)).toBeInTheDocument();
});
it("should show the select placeholder by default", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>,
);
renderWithProviders(<RateLimitTypeFormItem type="tpm" name="tpm_type" />);
expect(screen.getByText("Select rate limit type")).toBeInTheDocument();
});
it("should call onChange when provided", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" onChange={onChange} />
</Wrapper>,
);
renderWithProviders(<RateLimitTypeFormItem type="tpm" name="tpm_type" onChange={onChange} />);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Guaranteed throughput"));
expect(onChange).toHaveBeenCalledWith("guaranteed_throughput");

View file

@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react";
import { Input as AntdInput, InputNumber, Select } from "antd";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Info } from "lucide-react";
import { Tooltip } from "antd";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { UseFormSetValue } from "react-hook-form";
import { getOpenAPISchema } from "../networking";
import { formatLabel } from "@/utils/textUtils";
@ -57,6 +58,13 @@ const validateJSON = (value: string): boolean => {
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
const toSchemaNumber = (raw: string, isInteger: boolean): number | null => {
if (raw === "") return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
return isInteger ? Math.trunc(parsed) : parsed;
};
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const getFieldHelp = (key: string, property: SchemaProperty, type: string): string => {
@ -182,9 +190,9 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
const formLabel = tooltip ? (
<span>
{label}{" "}
<Tooltip title={tooltip}>
<SimpleTooltip content={tooltip}>
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</Tooltip>
</SimpleTooltip>
</span>
) : (
label
@ -204,7 +212,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
{(control) => {
if (isJSONField(key, property)) {
return (
<AntdInput.TextArea
<Textarea
{...control}
value={control.value as string | undefined}
rows={4}
@ -215,22 +223,34 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
}
if (property.enum) {
return (
<Select {...control} value={control.value as string | undefined}>
{property.enum.map((value) => (
<Select.Option key={value} value={value}>
{value}
</Select.Option>
))}
<Select value={(control.value as string | undefined) ?? null} onValueChange={control.onChange}>
<SelectTrigger
id={control.id}
onBlur={control.onBlur}
aria-invalid={control["aria-invalid"]}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{property.enum.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (type === "number" || type === "integer") {
return (
<InputNumber
<Input
{...control}
value={control.value as number | undefined}
style={{ width: "100%" }}
precision={type === "integer" ? 0 : undefined}
type="number"
step={type === "integer" ? 1 : "any"}
value={(control.value as number | undefined) ?? ""}
onChange={(event) => control.onChange(toSchemaNumber(event.target.value, type === "integer"))}
className="w-full"
/>
);
}

View file

@ -74,7 +74,7 @@ describe("UserSearchModal", () => {
expect(notice).toHaveTextContent(/users that already exist/i);
expect(notice).toHaveTextContent(/ask a proxy admin to create their account first/i);
// info, not warning: a warning here would read as an error state on an empty form
expect(notice.className).toMatch(/text-info/);
expect(notice).toHaveAttribute("data-variant", "info");
});
});

View file

@ -1,5 +1,5 @@
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Select } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Button } from "@/components/ui/button";
import { ArrowDown, Plus, X } from "lucide-react";
import React, { useState } from "react";
@ -94,18 +94,15 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
<div className="mb-3">
<label className="block text-xs font-medium text-gray-600 mb-1">Primary Model</label>
<Select
className="w-full"
placeholder="Select model"
value={entry.primaryModel}
onChange={(v) => {
const newFallbacks = entry.fallbackModels.filter((m) => m !== v);
updateEntry(entry.id, { primaryModel: v, fallbackModels: newFallbacks });
}}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
<SearchSelect
options={availablePrimaryOptions.map((m) => ({ label: m, value: m }))}
getPopupContainer={(trigger) => trigger.parentElement || document.body}
value={entry.primaryModel ?? ""}
onValueChange={(v) => {
const newFallbacks = entry.fallbackModels.filter((m) => m !== v);
updateEntry(entry.id, { primaryModel: v === "" ? null : v, fallbackModels: newFallbacks });
}}
placeholder="Select model"
emptyText="No models found"
/>
</div>
@ -118,23 +115,14 @@ export function BudgetFallbacksEditor({ value, onChange, availableModels }: Budg
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Fallback Models</label>
<Select
mode="multiple"
className="w-full"
placeholder={entry.primaryModel ? "Select fallback models" : "Select a primary model first"}
value={entry.fallbackModels}
onChange={(values) => updateEntry(entry.id, { fallbackModels: values })}
disabled={!entry.primaryModel}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
<MultiSelect
options={availableFallbackOptions.map((m) => ({ label: m, value: m }))}
getPopupContainer={(trigger) => trigger.parentElement || document.body}
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<SimpleTooltip content={omittedValues.map(({ value: v }) => v).join(", ")}>
<span>+{omittedValues.length} more</span>
</SimpleTooltip>
)}
value={entry.fallbackModels}
onValueChange={(values) => updateEntry(entry.id, { fallbackModels: values })}
placeholder={entry.primaryModel ? "Select fallback models" : "Select a primary model first"}
emptyText="No models found"
disabled={!entry.primaryModel}
className="w-full"
/>
{entry.fallbackModels.length > 1 && (
<div className="text-[10px] text-gray-400 mt-1 ml-1">

View file

@ -1,5 +1,6 @@
import { InputNumber, Select } from "antd";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import React from "react";
export interface BudgetWindowEntry {
@ -41,21 +42,43 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
<div key={idx} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Select
items={BUDGET_WINDOW_OPTIONS}
value={window.budget_duration}
onChange={(v) => updateWindow(idx, "budget_duration", v)}
style={{ width: 130 }}
options={BUDGET_WINDOW_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
<InputNumber
step={0.01}
min={0}
precision={2}
value={window.max_budget ?? undefined}
onChange={(v) => updateWindow(idx, "max_budget", v ?? null)}
placeholder="Max spend ($)"
style={{ width: 160 }}
prefix="$"
/>
onValueChange={(v: string | null) => v && updateWindow(idx, "budget_duration", v)}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_WINDOW_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputGroup className="w-40">
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
type="number"
step={0.01}
min={0}
value={window.max_budget ?? ""}
onChange={(event) => {
const typed = event.target.valueAsNumber;
updateWindow(idx, "max_budget", Number.isNaN(typed) ? null : typed);
}}
onBlur={(event) => {
const typed = event.target.valueAsNumber;
if (!Number.isNaN(typed)) {
updateWindow(idx, "max_budget", Number(typed.toFixed(2)));
}
}}
placeholder="Max spend ($)"
/>
</InputGroup>
<Button
variant="ghost"
size="sm"

View file

@ -197,10 +197,6 @@ const antdSearchInput = (placeholder: HTMLElement): HTMLInputElement => {
return input;
};
const openAntdSelect = async (placeholder: HTMLElement) => {
await userEvent.click(antdSearchInput(placeholder));
};
const openSection = async (name: RegExp) => {
await userEvent.click(await screen.findByRole("button", { name }));
};
@ -468,8 +464,9 @@ describe("CreateKey", () => {
await nameTheKey();
await openSection(/Optional Settings/i);
await openAntdSelect(await screen.findByText("Select access groups (optional)"));
await userEvent.click(await screen.findByText("Group One"));
await userEvent.click(await screen.findByLabelText("Select access groups (optional)"));
await userEvent.click(await screen.findByRole("option", { name: /Group One/ }));
await userEvent.keyboard("{Escape}");
await submit();
expect((await createdPayload()).access_group_ids).toStrictEqual(["ag-1"]);

View file

@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./Alert";
describe("Alert", () => {
it("exposes the alert role plus the slot and variant hooks", () => {
render(
<Alert variant="warning">
<AlertTitle>License expiring</AlertTitle>
<AlertDescription>Renew before it lapses.</AlertDescription>
</Alert>,
);
const alert = screen.getByRole("alert");
expect(alert).toHaveAttribute("data-slot", "alert");
expect(alert).toHaveAttribute("data-variant", "warning");
expect(alert).toHaveTextContent("License expiring");
expect(alert).toHaveTextContent("Renew before it lapses.");
});
it("reports the default variant when none is given", () => {
render(<Alert>Heads up</Alert>);
expect(screen.getByRole("alert")).toHaveAttribute("data-variant", "default");
});
it.each([
["info", "text-info"],
["success", "text-success"],
["warning", "text-warning"],
["error", "text-red-900"],
["destructive", "text-destructive"],
] as const)("paints the %s variant with its own token color", (variant, tokenClass) => {
render(<Alert variant={variant}>message</Alert>);
const alert = screen.getByRole("alert");
expect(alert).toHaveAttribute("data-variant", variant);
expect(alert).toHaveClass(tokenClass);
});
it("keeps success off the warning color so a save does not read as a problem", () => {
render(<Alert variant="success">saved</Alert>);
expect(screen.getByRole("alert")).not.toHaveClass("text-warning");
});
it("lets className win over the variant classes through twMerge", () => {
render(
<Alert variant="info" className="bg-card">
message
</Alert>,
);
const alert = screen.getByRole("alert");
expect(alert).toHaveClass("bg-card");
expect(alert).not.toHaveClass("bg-info/5");
});
it("renders the action slot so a dismiss control stays reachable", () => {
render(
<Alert variant="info">
<AlertTitle>SSO enabled</AlertTitle>
<AlertAction>
<button type="button" aria-label="Close" />
</AlertAction>
</Alert>,
);
const action = screen.getByRole("button", { name: "Close" }).parentElement;
expect(action).toHaveAttribute("data-slot", "alert-action");
});
it("tags the title and description parts for the layout selectors", () => {
render(
<Alert>
<AlertTitle>Title</AlertTitle>
<AlertDescription>Description</AlertDescription>
</Alert>,
);
expect(screen.getByText("Title")).toHaveAttribute("data-slot", "alert-title");
expect(screen.getByText("Description")).toHaveAttribute("data-slot", "alert-description");
});
});

View file

@ -22,8 +22,15 @@ const alertVariants = cva({
type AlertProps = React.ComponentProps<"div"> & VariantProps<typeof alertVariants>;
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(({ className, variant, ...props }, ref) => (
<div ref={ref} data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
data-slot="alert"
data-variant={variant}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";

View file

@ -93,6 +93,31 @@ describe("MultiSelect", () => {
expect(await screen.findByText("No options found")).toBeInTheDocument();
});
it("splits a comma-separated custom entry into one value per token", async () => {
const { onValueChange, input } = renderMultiSelect({ allowCustomValues: true });
await userEvent.type(input, "udp, kafka ,terraform");
await userEvent.click(await screen.findByText('Create "udp, kafka ,terraform"'));
expect(onValueChange).toHaveBeenCalledWith(["udp", "kafka", "terraform"]);
});
it("clears every selection at once", async () => {
const onValueChange = vi.fn();
render(
<MultiSelect
options={OPTIONS}
value={["vs-alpha", "vs-beta"]}
onValueChange={onValueChange}
placeholder="Select stores"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Clear all" }));
expect(onValueChange).toHaveBeenCalledWith([]);
});
it("marks a disabled option as disabled and refuses to select it", async () => {
const { onValueChange, input } = renderMultiSelect({
options: [OPTIONS[0], { ...OPTIONS[1], disabled: true }],

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxClear,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
@ -34,6 +35,12 @@ interface MultiSelectProps {
className?: string;
}
const splitOnCommas = (raw: string): string[] =>
raw
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0);
const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
const normalizedQuery = query.trim().toLowerCase();
return (
@ -78,15 +85,22 @@ export function MultiSelect({
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
: safeOptions;
const canClear = (selected: MultiSelectOption[]) => selected.length > 0 && !disabled && !loading;
const handleValueChange = (selected: MultiSelectOption[]) => {
const next = allowCustomValues
? selected.flatMap((option) => splitOnCommas(option.value))
: selected.map((option) => option.value);
onValueChange(Array.from(new Set(next)));
setQuery("");
};
return (
<Combobox
multiple
items={items}
value={selectedOptions}
onValueChange={(selected: MultiSelectOption[]) => {
onValueChange(selected.map((option) => option.value));
setQuery("");
}}
onValueChange={handleValueChange}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value}
@ -109,6 +123,7 @@ export function MultiSelect({
className="min-w-24"
aria-label={placeholder || undefined}
/>
{canClear(selected) && <ComboboxClear className="ml-auto self-center" aria-label="Clear all" />}
</>
)}
</ComboboxValue>

View file

@ -25,7 +25,7 @@ interface PaginatedSearchSelectProps {
value?: string;
onValueChange: (value: string) => void;
onSearchChange: (query: string) => void;
onLoadMore: () => void;
onLoadMore?: () => void;
hasNextPage?: boolean;
isLoading?: boolean;
isFetchingNextPage?: boolean;
@ -82,7 +82,7 @@ export function PaginatedSearchSelect({
if (target.scrollHeight === 0) return;
const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
onLoadMore();
onLoadMore?.();
}
};

View file

@ -1,5 +1,6 @@
"use client";
import React from "react";
import {
Combobox,
ComboboxContent,
@ -14,6 +15,8 @@ export interface SearchSelectOption {
value: string;
/** Optional muted second line (e.g. an id); also matched when searching. */
sublabel?: string;
/** Optional leading glyph (e.g. a provider logo); not matched when searching. */
icon?: React.ReactNode;
}
interface SearchSelectProps {
@ -26,6 +29,7 @@ interface SearchSelectProps {
className?: string;
inputId?: string;
allowClear?: boolean;
"aria-label"?: string;
}
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
@ -44,6 +48,7 @@ export function SearchSelect({
className,
inputId,
allowClear = true,
"aria-label": ariaLabel,
}: SearchSelectProps) {
const selected =
value === undefined || value === ""
@ -64,6 +69,7 @@ export function SearchSelect({
>
<ComboboxInput
id={inputId}
aria-label={ariaLabel}
placeholder={placeholder}
showClear={allowClear && value != null && value !== ""}
className={`h-8 w-full text-sm ${className ?? ""}`}
@ -73,6 +79,7 @@ export function SearchSelect({
<ComboboxList>
{(item: SearchSelectOption) => (
<ComboboxItem key={item.value} value={item}>
{item.icon}
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
{item.sublabel != null && item.sublabel !== "" && (

View file

@ -11,9 +11,11 @@ const badgeVariants = cva({
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
info: "bg-info/10 text-info [a]:hover:bg-info/20",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
success: "bg-success/10 text-success dark:bg-success/20 [a]:hover:bg-success/20",
warning: "bg-warning/10 text-warning dark:bg-warning/20 [a]:hover:bg-warning/20",
info: "bg-info/10 text-info dark:bg-info/20 [a]:hover:bg-info/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",

View file

@ -1,13 +1,8 @@
"use client";
import React from "react";
import { ConfigProvider } from "antd";
import { StyleProvider } from "@ant-design/cssinjs";
export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) {
return (
<StyleProvider layer>
<ConfigProvider theme={{ cssVar: true }}>{children}</ConfigProvider>
</StyleProvider>
);
return <StyleProvider layer>{children}</StyleProvider>;
}