refactor(ui): migrate user, policy, and margin forms to shadcn (#37305)

* refactor(ui): migrate user, policy, and margin forms to shadcn

Move four dashboard forms off antd Form and Tremor onto the shadcn field
kit, with react-hook-form where the form owns its own submit. Submit
payloads are unchanged: edit_user still emits exactly six keys with spend
as a number and max_budget as a string, policy_test_panel still omits
empty context keys, and add_attachment_form still builds the same
attachment body.

add_margin_form had no Form of its own and no bound field names, so its
Form.Item rules were inert; it keeps its parent-owned state props and
only swaps the presentation.

Adds characterization tests for edit_user and policy_test_panel, both
proven green against the antd originals before the migration, plus a case
pinning the provider value add_margin_form reports upward. The existing
add_attachment_form and add_margin_form suites pass unedited.

Extracts TokenSelect for the tag and alias inputs shared across the
policy forms, keeping antd's token separators and blur-commit behavior.

* refactor(ui): seed the user edit form by remount instead of an effect

Key the form on the edited user so react-hook-form seeds from its
defaults on each user, replacing the effect that reset the form and the
exhaustive-deps suppression that came with it. Cancel and submit still
reset, so reopening the same user shows stored values.

* test(ui): pin that the user edit form forwards null fields unchanged

The proxy returns null rather than omitting unset fields, and antd
forwarded whatever it received. The only fixture seeded every field, so
nothing proved the migrated form still emits null instead of an empty
string. Proven against the antd original first, and it fails if
toFormValues coerces.
This commit is contained in:
yuneng-jiang 2026-08-18 11:42:16 -07:00 • committed by GitHub
parent 5e2d6addc4
commit 2ee15a6efb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1251 additions and 491 deletions

View file

@ -199,9 +199,6 @@
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": {
@ -1096,7 +1093,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/immutability": {
"count": 1
@ -1210,7 +1207,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/immutability": {
"count": 1
@ -1493,7 +1490,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/users/_components/index.tsx": {

View file

@ -1,7 +1,7 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { renderWithProviders } from "../../../../../tests/test-utils";
import AddMarginForm from "./add_margin_form";
import { MarginConfig } from "./types";
@ -103,4 +103,16 @@ describe("AddMarginForm", () => {
await user.click(screen.getByText("Fixed Amount"));
expect(onMarginTypeChange).toHaveBeenCalledWith("fixed");
});
it("should call onProviderChange with the provider key when a provider is picked", async () => {
const onProviderChange = vi.fn();
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} onProviderChange={onProviderChange} />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Anthropic"));
expect(onProviderChange.mock.calls).toHaveLength(1);
expect(onProviderChange.mock.calls[0]?.[0]).toBe("Anthropic");
});
});

View file

@ -1,9 +1,20 @@
import React from "react";
import { TextInput, Button } from "@tremor/react";
import { Select as AntdSelect, Form, Tooltip, Radio } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { CircleHelp } from "lucide-react";
import { Providers, provider_map } from "@/components/provider_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
import { Field, FieldLabel, FieldTitle } from "@/components/shared/form/field";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MarginConfig } from "./types";
interface AddMarginFormProps {
@ -19,6 +30,39 @@ interface AddMarginFormProps {
onAddProvider: () => void;
}
interface ProviderOption {
value: string;
label: string;
providerEnum: string | null;
}
const GLOBAL_OPTION: ProviderOption = {
value: "global",
label: "Global (All Providers)",
providerEnum: null,
};
const buildProviderOptions = (marginConfig: MarginConfig): ProviderOption[] => [
GLOBAL_OPTION,
...Object.entries(Providers).flatMap(([providerEnum, providerDisplayName]) => {
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
if (providerValue && marginConfig[providerValue]) {
return [];
}
return [{ value: providerEnum, label: providerDisplayName, providerEnum }];
}),
];
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const AddMarginForm: React.FC<AddMarginFormProps> = ({
marginConfig,
selectedProvider,
@ -31,163 +75,116 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
onFixedAmountChange,
onAddProvider,
}) => {
const providerOptions = buildProviderOptions(marginConfig);
const selectedOption = providerOptions.find((option) => option.value === selectedProvider) ?? null;
return (
<div className="space-y-6">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Provider
<Tooltip title="Select 'Global' to apply margin to all providers, or select a specific provider">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
rules={[{ required: true, message: "Please select a provider" }]}
>
<AntdSelect
showSearch
placeholder="Select provider or 'Global'"
value={selectedProvider}
onChange={onProviderChange}
style={{ width: "100%" }}
size="large"
optionFilterProp="children"
filterOption={(input, option) =>
String(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
>
<AntdSelect.Option key="global" value="global" label="Global (All Providers)">
<div className="flex items-center space-x-2">
<span className="font-medium">Global (All Providers)</span>
<TooltipProvider>
<div className="space-y-6">
<Field>
<FieldLabel htmlFor="margin-provider">
{labelWithHint(
"Provider",
"Select 'Global' to apply margin to all providers, or select a specific provider",
)}
</FieldLabel>
<Combobox
items={providerOptions}
value={selectedOption}
onValueChange={(option: ProviderOption | null) => onProviderChange(option?.value)}
itemToStringLabel={(option: ProviderOption) => option.label}
isItemEqualToValue={(option: ProviderOption, selected: ProviderOption) => option.value === selected.value}
>
<ComboboxInput id="margin-provider" placeholder="Select provider or 'Global'" className="w-full" />
<ComboboxContent>
<ComboboxEmpty>No matching providers</ComboboxEmpty>
<ComboboxList>
{(option: ProviderOption) => (
<ComboboxItem key={option.value} value={option}>
<span className="flex items-center space-x-2">
{option.providerEnum !== null && (
<Logo provider={option.providerEnum} label={option.label} className="w-5 h-5" />
)}
<span className={option.providerEnum === null ? "font-medium" : undefined}>{option.label}</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
<Field>
<FieldTitle>
{labelWithHint("Margin Type", "Choose how to apply the margin: percentage-based or fixed amount")}
</FieldTitle>
<RadioGroup
value={marginType}
onValueChange={(value: unknown) => onMarginTypeChange(value as "percentage" | "fixed")}
className="w-full"
>
<FieldLabel className="font-normal">
<RadioGroupItem value="percentage" />
Percentage-based
</FieldLabel>
<FieldLabel className="font-normal">
<RadioGroupItem value="fixed" />
Fixed Amount
</FieldLabel>
</RadioGroup>
</Field>
{marginType === "percentage" && (
<Field>
<FieldLabel htmlFor="margin-percentage">
{labelWithHint("Margin Percentage", "Enter a percentage value (e.g., 10 for 10% margin)")}
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="margin-percentage"
placeholder="10"
value={percentageValue}
onChange={(event) => onPercentageChange(event.target.value)}
className="rounded-lg flex-1"
/>
<span className="text-muted-foreground">%</span>
</div>
</AntdSelect.Option>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
// Only show providers that don't already have a margin configured
if (providerValue && marginConfig[providerValue]) {
return null;
</Field>
)}
{marginType === "fixed" && (
<Field>
<FieldLabel htmlFor="margin-fixed-amount">
{labelWithHint("Fixed Margin Amount", "Enter a fixed amount in USD (e.g., 0.001 for $0.001 per request)")}
</FieldLabel>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">$</span>
<Input
id="margin-fixed-amount"
placeholder="0.001"
value={fixedAmountValue}
onChange={(event) => onFixedAmountChange(event.target.value)}
className="rounded-lg flex-1"
/>
</div>
</Field>
)}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
<Button
type="button"
onClick={onAddProvider}
disabled={
!selectedProvider ||
(marginType === "percentage" && !percentageValue) ||
(marginType === "fixed" && !fixedAmountValue)
}
return (
<AntdSelect.Option key={providerEnum} value={providerEnum} label={providerDisplayName}>
<div className="flex items-center space-x-2">
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
<span>{providerDisplayName}</span>
</div>
</AntdSelect.Option>
);
})}
</AntdSelect>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Margin Type
<Tooltip title="Choose how to apply the margin: percentage-based or fixed amount">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
rules={[{ required: true, message: "Please select a margin type" }]}
>
<Radio.Group value={marginType} onChange={(e) => onMarginTypeChange(e.target.value)} className="w-full">
<Radio value="percentage">Percentage-based</Radio>
<Radio value="fixed">Fixed Amount</Radio>
</Radio.Group>
</Form.Item>
{marginType === "percentage" && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Margin Percentage
<Tooltip title="Enter a percentage value (e.g., 10 for 10% margin)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
rules={[
{ required: true, message: "Please enter a margin percentage" },
{
validator: (_, value) => {
if (!value) {
return Promise.reject(new Error("Please enter a margin percentage"));
}
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0 || numValue > 1000) {
return Promise.reject(new Error("Percentage must be between 0 and 1000"));
}
return Promise.resolve();
},
},
]}
>
<div className="flex items-center gap-2">
<TextInput
placeholder="10"
value={percentageValue}
onValueChange={onPercentageChange}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 flex-1"
/>
<span className="text-gray-600">%</span>
</div>
</Form.Item>
)}
{marginType === "fixed" && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Fixed Margin Amount
<Tooltip title="Enter a fixed amount in USD (e.g., 0.001 for $0.001 per request)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
rules={[
{ required: true, message: "Please enter a fixed amount" },
{
validator: (_, value) => {
if (!value) {
return Promise.reject(new Error("Please enter a fixed amount"));
}
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) {
return Promise.reject(new Error("Fixed amount must be non-negative"));
}
return Promise.resolve();
},
},
]}
>
<div className="flex items-center gap-2">
<span className="text-gray-600">$</span>
<TextInput
placeholder="0.001"
value={fixedAmountValue}
onValueChange={onFixedAmountChange}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 flex-1"
/>
</div>
</Form.Item>
)}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
<Button
variant="primary"
onClick={onAddProvider}
disabled={
!selectedProvider ||
(marginType === "percentage" && !percentageValue) ||
(marginType === "fixed" && !fixedAmountValue)
}
>
Add Provider Margin
</Button>
>
Add Provider Margin
</Button>
</div>
</div>
</div>
</TooltipProvider>
);
};

View file

@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
interface TokenSelectProps {
id: string;
value: readonly string[] | undefined;
onValueChange: (value: string[]) => void;
onBlur?: () => void;
placeholder: string;
options?: readonly string[];
allowCustomValues?: boolean;
tokenSeparators?: readonly string[];
emptyText?: string;
ariaInvalid?: true;
ariaDescribedBy?: string;
}
const splitOnSeparators = (text: string, separators: readonly string[]): string[] =>
separators.reduce<string[]>((parts, separator) => parts.flatMap((part) => part.split(separator)), [text]);
const withAdditions = (current: readonly string[], additions: readonly string[]): string[] => [
...current,
...additions.filter((addition) => addition !== "" && !current.includes(addition)),
];
export const includesQuery = (item: string, query: string): boolean => item.toLowerCase().includes(query.toLowerCase());
export const TokenSelect: React.FC<TokenSelectProps> = ({
id,
value,
onValueChange,
onBlur,
placeholder,
options,
allowCustomValues = false,
tokenSeparators = [],
emptyText = "No options found",
ariaInvalid,
ariaDescribedBy,
}) => {
const anchor = useComboboxAnchor();
const [query, setQuery] = React.useState("");
const selected = value ?? [];
const showDropdown = options !== undefined;
const pendingCustomValue = allowCustomValues && query.trim() !== "" && !options?.includes(query.trim());
const items = pendingCustomValue ? [...(options ?? []), query.trim()] : options ?? [];
const handleInputValueChange = (next: string) => {
if (!allowCustomValues || !tokenSeparators.some((separator) => next.includes(separator))) {
setQuery(next);
return;
}
const parts = splitOnSeparators(next, tokenSeparators);
const committed = parts.slice(0, -1).map((part) => part.trim());
onValueChange(withAdditions(selected, committed));
setQuery(parts[parts.length - 1]);
};
const handleBlur = () => {
const pending = query.trim();
if (allowCustomValues && pending !== "") {
onValueChange(withAdditions(selected, [pending]));
}
setQuery("");
onBlur?.();
};
return (
<Combobox
multiple
autoHighlight={showDropdown}
open={showDropdown ? undefined : false}
items={items}
value={selected as string[]}
onValueChange={(next: string[]) => {
onValueChange(next);
setQuery("");
}}
inputValue={query}
onInputValueChange={handleInputValueChange}
filter={includesQuery}
>
<ComboboxChips render={<div ref={anchor} />}>
<ComboboxValue>
{(chips: string[]) => (
<>
{chips.map((chip) => (
<ComboboxChip key={chip} aria-label={chip}>
{chip}
</ComboboxChip>
))}
<ComboboxChipsInput
id={id}
placeholder={placeholder}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
onBlur={handleBlur}
/>
</>
)}
</ComboboxValue>
</ComboboxChips>
{showDropdown && (
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item} title={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
)}
</Combobox>
);
};

View file

@ -1,15 +1,23 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Select, Radio, Divider, Typography } from "antd";
import { Button } from "@tremor/react";
import { Modal } from "antd";
import { CircleHelp } from "lucide-react";
import { z } from "zod/v4";
import { Policy } from "@/components/policies/types";
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup, FieldLabel, FieldTitle } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import { buildAttachmentData } from "./build_attachment_data";
import { getInvalidTeamEntries } from "./scope_validation";
import ImpactPreviewAlert from "./impact_preview_alert";
const { Text } = Typography;
import { TokenSelect } from "./TokenSelect";
interface AddAttachmentFormProps {
visible: boolean;
@ -20,6 +28,60 @@ interface AddAttachmentFormProps {
createAttachment: (accessToken: string, attachmentData: any) => Promise<any>;
}
type ScopeType = "global" | "specific";
interface AttachmentFormValues {
policy_names: string[];
teams: string[];
keys: string[];
models: string[];
tags: string[];
}
const EMPTY_VALUES: AttachmentFormValues = {
policy_names: [],
teams: [],
keys: [],
models: [],
tags: [],
};
const attachmentShape = {
policy_names: z.array(z.string()).min(1, "Please select at least one policy"),
teams: z.array(z.string()),
keys: z.array(z.string()),
models: z.array(z.string()),
tags: z.array(z.string()),
};
const buildAttachmentSchema = (scopeType: ScopeType, teamsLoaded: boolean, availableTeams: string[]) =>
z.object(attachmentShape).superRefine((values, ctx) => {
if (scopeType !== "specific" || !teamsLoaded) {
return;
}
const invalid = getInvalidTeamEntries(values.teams, availableTeams);
if (invalid.length === 0) {
return;
}
ctx.addIssue({
code: "custom",
path: ["teams"],
message:
`These teams don't exist: ${invalid.join(", ")}. ` +
`Choose an existing team, or use a wildcard like "team-*" to match by prefix.`,
});
});
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
visible,
onClose,
@ -28,9 +90,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
policies,
createAttachment,
}) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopeType, setScopeType] = useState<"global" | "specific">("global");
const [scopeType, setScopeType] = useState<ScopeType>("global");
const [availableTeams, setAvailableTeams] = useState<string[]>([]);
const [teamsLoaded, setTeamsLoaded] = useState(false);
const [availableKeys, setAvailableKeys] = useState<string[]>([]);
@ -41,6 +102,9 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
const [isEstimating, setIsEstimating] = useState(false);
const [impactResult, setImpactResult] = useState<any>(null);
const { userId, userRole } = useAuthorized();
const form = useZodForm(buildAttachmentSchema(scopeType, teamsLoaded, availableTeams), {
defaultValues: EMPTY_VALUES,
});
useEffect(() => {
if (visible && accessToken) {
@ -95,30 +159,22 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
};
const resetForm = () => {
form.resetFields();
form.reset(EMPTY_VALUES);
setScopeType("global");
setImpactResult(null);
};
const handlePreviewImpact = async () => {
if (!accessToken) return;
try {
await form.validateFields(["policy_names"]);
} catch {
if (!(await form.trigger("policy_names"))) {
return;
}
setIsEstimating(true);
try {
const { policy_names = [] } = form.getFieldsValue(true);
const firstPolicy = policy_names?.[0];
const values = form.getValues();
const firstPolicy = values.policy_names[0];
if (!firstPolicy) return;
const data = buildAttachmentData(
{
...form.getFieldsValue(true),
policy_name: firstPolicy,
},
scopeType,
);
const data = buildAttachmentData({ ...values, policy_name: firstPolicy }, scopeType);
const result = await estimateAttachmentImpactCall(accessToken, data);
setImpactResult(result);
} catch (error) {
@ -133,27 +189,17 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
onClose();
};
const handleSubmit = async () => {
const handleSubmit = async (values: AttachmentFormValues) => {
try {
setIsSubmitting(true);
await form.validateFields();
if (!accessToken) {
throw new Error("No access token available");
}
const values = form.getFieldsValue(true);
const selectedPolicyNames: string[] = values.policy_names || [];
const results = await Promise.allSettled(
selectedPolicyNames.map((policyName) => {
const data = buildAttachmentData(
{
...values,
policy_name: policyName,
},
scopeType,
);
values.policy_names.map((policyName) => {
const data = buildAttachmentData({ ...values, policy_name: policyName }, scopeType);
return createAttachment(accessToken, data);
}),
);
@ -182,165 +228,207 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
}
};
const policyOptions = policies.map((p) => ({
label: p.policy_name,
value: p.policy_name,
}));
const policyOptions = policies.map((p) => p.policy_name);
return (
<Modal title="Create Policy Attachment" open={visible} onCancel={handleClose} footer={null} width={600}>
<Form
form={form}
layout="vertical"
initialValues={{
scope_type: "global",
}}
>
<Form.Item
name="policy_names"
label="Policies"
rules={[{ required: true, message: "Please select at least one policy" }]}
>
<Select
mode="multiple"
placeholder="Select policies to attach"
options={policyOptions}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
style={{ width: "100%" }}
/>
</Form.Item>
<TooltipProvider>
<form onSubmit={(event) => event.preventDefault()} noValidate>
<FieldGroup>
<FormField control={form.control} name="policy_names" label="Policies">
{({ id, value, onChange, onBlur, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder="Select policies to attach"
options={policyOptions}
emptyText="No matching policies"
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
<Divider orientation="left">
<Text strong>Scope</Text>
</Divider>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold">Scope</span>
<Separator className="flex-1" />
</div>
<Form.Item label="Scope Type">
<Radio.Group value={scopeType} onChange={(e) => setScopeType(e.target.value)}>
<Radio value="specific">Specific (teams, keys, models, or tags)</Radio>
<Radio value="global">Global (applies to all requests)</Radio>
</Radio.Group>
</Form.Item>
<div>
<FieldTitle className="mb-2">Scope Type</FieldTitle>
<RadioGroup value={scopeType} onValueChange={(value: unknown) => setScopeType(value as ScopeType)}>
<FieldLabel className="font-normal">
<RadioGroupItem value="specific" />
Specific (teams, keys, models, or tags)
</FieldLabel>
<FieldLabel className="font-normal">
<RadioGroupItem value="global" />
Global (applies to all requests)
</FieldLabel>
</RadioGroup>
</div>
{scopeType === "specific" && (
<>
<Form.Item
name="teams"
label="Teams"
tooltip="Select team aliases or enter custom patterns. Supports wildcards (e.g., healthcare-*)"
rules={[
{
validator: async (_rule, value?: string[]) => {
if (!teamsLoaded) return;
const invalid = getInvalidTeamEntries(value ?? [], availableTeams);
if (invalid.length > 0) {
throw new Error(
`These teams don't exist: ${invalid.join(", ")}. ` +
`Choose an existing team, or use a wildcard like "team-*" to match by prefix.`,
);
}
},
},
]}
>
<Select
mode="tags"
placeholder={isLoadingTeams ? "Loading teams..." : "Select or enter team aliases"}
loading={isLoadingTeams}
options={availableTeams.map((team) => ({
label: team,
value: team,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
style={{ width: "100%" }}
/>
</Form.Item>
{scopeType === "specific" && (
<>
<FormField
control={form.control}
name="teams"
label={labelWithHint(
"Teams",
"Select team aliases or enter custom patterns. Supports wildcards (e.g., healthcare-*)",
)}
>
{({
id,
value,
onChange,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder={isLoadingTeams ? "Loading teams..." : "Select or enter team aliases"}
options={availableTeams}
allowCustomValues
tokenSeparators={[","]}
emptyText="No matching teams"
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
<Form.Item
name="keys"
label="Keys"
tooltip="Select key aliases or enter custom patterns. Supports wildcards (e.g., dev-*)"
>
<Select
mode="tags"
placeholder={isLoadingKeys ? "Loading keys..." : "Select or enter key aliases"}
loading={isLoadingKeys}
options={availableKeys.map((key) => ({
label: key,
value: key,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
style={{ width: "100%" }}
/>
</Form.Item>
<FormField
control={form.control}
name="keys"
label={labelWithHint(
"Keys",
"Select key aliases or enter custom patterns. Supports wildcards (e.g., dev-*)",
)}
>
{({
id,
value,
onChange,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder={isLoadingKeys ? "Loading keys..." : "Select or enter key aliases"}
options={availableKeys}
allowCustomValues
tokenSeparators={[","]}
emptyText="No matching keys"
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
<Form.Item
name="models"
label="Models"
tooltip="Model names this attachment applies to. Supports wildcards (e.g., gpt-4*). Leave empty to apply to all models."
>
<Select
mode="tags"
placeholder={
isLoadingModels ? "Loading models..." : "Select or enter model names (e.g., gpt-4, bedrock/*)"
}
loading={isLoadingModels}
options={availableModels.map((model) => ({
label: model,
value: model,
}))}
tokenSeparators={[","]}
showSearch
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
style={{ width: "100%" }}
/>
</Form.Item>
<FormField
control={form.control}
name="models"
label={labelWithHint(
"Models",
"Model names this attachment applies to. Supports wildcards (e.g., gpt-4*). Leave empty to apply to all models.",
)}
>
{({
id,
value,
onChange,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder={
isLoadingModels ? "Loading models..." : "Select or enter model names (e.g., gpt-4, bedrock/*)"
}
options={availableModels}
allowCustomValues
tokenSeparators={[","]}
emptyText="No matching models"
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
<Form.Item
name="tags"
label="Tags"
tooltip="Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix."
extra={
<Text type="secondary" style={{ fontSize: 12 }}>
Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body.
Use <code>*</code> as a suffix wildcard (e.g., <code>prod-*</code> matches <code>prod-us</code>,{" "}
<code>prod-eu</code>).
</Text>
}
>
<Select
mode="tags"
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
tokenSeparators={[",", " "]}
notFoundContent={null}
suffixIcon={null}
open={false}
style={{ width: "100%" }}
/>
</Form.Item>
</>
)}
<FormField
control={form.control}
name="tags"
label={labelWithHint(
"Tags",
"Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix.",
)}
description={
<span className="text-xs">
Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request
body. Use <code>*</code> as a suffix wildcard (e.g., <code>prod-*</code> matches{" "}
<code>prod-us</code>, <code>prod-eu</code>).
</span>
}
>
{({
id,
value,
onChange,
onBlur,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
allowCustomValues
tokenSeparators={[",", " "]}
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
</>
)}
</FieldGroup>
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
<div className="flex justify-end space-x-2 mt-4">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
{scopeType === "specific" && (
<Button variant="secondary" onClick={handlePreviewImpact} loading={isEstimating}>
Estimate Impact
<div className="flex justify-end space-x-2 mt-4">
<Button type="button" variant="secondary" onClick={handleClose}>
Cancel
</Button>
)}
<Button onClick={handleSubmit} loading={isSubmitting}>
Create Attachment
</Button>
</div>
</Form>
{scopeType === "specific" && (
<Button type="button" variant="secondary" onClick={handlePreviewImpact} disabled={isEstimating}>
{isEstimating && <UiLoadingSpinner className="size-4" />}
Estimate Impact
</Button>
)}
<Button type="button" onClick={form.handleSubmit(handleSubmit)} disabled={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
Create Attachment
</Button>
</div>
</form>
</TooltipProvider>
</Modal>
);
};

View file

@ -0,0 +1,149 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import * as networking from "@/components/networking";
import PolicyTestPanel from "./policy_test_panel";
vi.mock("@/components/networking");
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ userId: "admin-user-id", userRole: "Admin", accessToken: "test-token" }),
}));
const RESOLVED = {
effective_guardrails: ["pii-masking"],
matched_policies: [{ policy_name: "policy-alpha", matched_via: "team_alias", guardrails_added: ["pii-masking"] }],
};
const setup = () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(<PolicyTestPanel accessToken="test-token" />);
return user;
};
const pickOption = async (user: ReturnType<typeof userEvent.setup>, label: string, option: string) => {
await user.click(screen.getByLabelText(label));
await user.click(await screen.findByTitle(option));
};
const simulate = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "Simulate" }));
};
describe("PolicyTestPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.teamListCall).mockResolvedValue([
{ team_alias: "team-a" },
{ team_alias: "team-b" },
] as unknown as Awaited<ReturnType<typeof networking.teamListCall>>);
vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [{ key_alias: "key-a" }] } as unknown as Awaited<
ReturnType<typeof networking.keyListCall>
>);
vi.mocked(networking.modelAvailableCall).mockResolvedValue({ data: [{ id: "gpt-4" }] } as unknown as Awaited<
ReturnType<typeof networking.modelAvailableCall>
>);
vi.mocked(networking.resolvePoliciesCall).mockResolvedValue(RESOLVED);
});
it("loads teams, keys and models for the dropdowns", async () => {
setup();
await waitFor(() => expect(networking.teamListCall).toHaveBeenCalledWith("test-token", null, "admin-user-id"));
expect(networking.keyListCall).toHaveBeenCalledWith("test-token", null, null, null, null, null, 1, 100);
expect(networking.modelAvailableCall).toHaveBeenCalledWith("test-token", "admin-user-id", "Admin");
});
it("sends an empty context when nothing has been picked", async () => {
const user = setup();
await simulate(user);
await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", {});
});
it("sends only the fields that were filled in", async () => {
const user = setup();
await waitFor(() => expect(networking.teamListCall).toHaveBeenCalled());
await pickOption(user, "Team Alias", "team-b");
await pickOption(user, "Model", "gpt-4");
await simulate(user);
await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", {
team_alias: "team-b",
model: "gpt-4",
});
});
it("turns each token-separated entry into its own tag", async () => {
const user = setup();
const tags = screen.getByLabelText("Tags");
await user.click(tags);
await user.type(tags, "prod-us,");
await user.type(tags, "healthcare ");
await simulate(user);
await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", { tags: ["prod-us", "healthcare"] });
});
it("commits tag text still sitting in the box when the field loses focus", async () => {
const user = setup();
const tags = screen.getByLabelText("Tags");
await user.click(tags);
await user.type(tags, "prod-us");
await simulate(user);
await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(1));
expect(networking.resolvePoliciesCall).toHaveBeenCalledWith("test-token", { tags: ["prod-us"] });
});
it("shows the placeholder before the first run, then the results", async () => {
const user = setup();
expect(screen.getByText("No simulation run yet")).toBeInTheDocument();
await simulate(user);
expect(await screen.findByText("Effective Guardrails")).toBeInTheDocument();
expect(screen.getByText("Matched Policies")).toBeInTheDocument();
expect(screen.getByText("policy-alpha")).toBeInTheDocument();
expect(screen.getByText("team_alias")).toBeInTheDocument();
expect(screen.queryByText("No simulation run yet")).not.toBeInTheDocument();
});
it("reports a failed resolve without results", async () => {
vi.mocked(networking.resolvePoliciesCall).mockRejectedValue(new Error("boom"));
const user = setup();
await simulate(user);
expect(await screen.findByText("Failed to resolve policies. Check the proxy logs.")).toBeInTheDocument();
});
it("clears both the picked context and the results on Reset", async () => {
const user = setup();
await waitFor(() => expect(networking.teamListCall).toHaveBeenCalled());
await pickOption(user, "Team Alias", "team-a");
await simulate(user);
await screen.findByText("Effective Guardrails");
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByText("No simulation run yet")).toBeInTheDocument();
await simulate(user);
await waitFor(() => expect(networking.resolvePoliciesCall).toHaveBeenCalledTimes(2));
expect(vi.mocked(networking.resolvePoliciesCall).mock.calls[1][1]).toEqual({});
});
it("does not resolve anything without an access token", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(<PolicyTestPanel accessToken={null} />);
expect(networking.teamListCall).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Simulate" }));
expect(networking.resolvePoliciesCall).not.toHaveBeenCalled();
});
});

View file

@ -1,10 +1,22 @@
import React, { useState, useEffect } from "react";
import { Form, Select, Alert, Tag, Empty, Typography } from "antd";
import { Button } from "@tremor/react";
import { useForm } from "react-hook-form";
import { Alert, Empty } from "antd";
import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const { Text } = Typography;
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { TokenSelect, includesQuery } from "./TokenSelect";
interface PolicyTestPanelProps {
accessToken: string | null;
@ -21,8 +33,65 @@ interface ResolveResult {
matched_policies: PolicyMatchDetail[];
}
interface PolicyTestFormValues {
team_alias: string | undefined;
key_alias: string | undefined;
model: string | undefined;
tags: string[] | undefined;
}
interface ResolveContext {
team_alias?: string;
key_alias?: string;
model?: string;
tags?: string[];
}
const EMPTY_VALUES: PolicyTestFormValues = {
team_alias: undefined,
key_alias: undefined,
model: undefined,
tags: undefined,
};
const buildResolveContext = (values: PolicyTestFormValues): ResolveContext => ({
...(values.team_alias ? { team_alias: values.team_alias } : {}),
...(values.key_alias ? { key_alias: values.key_alias } : {}),
...(values.model ? { model: values.model } : {}),
...(values.tags && values.tags.length > 0 ? { tags: values.tags } : {}),
});
interface ContextComboboxProps {
id: string;
value: string | undefined;
onChange: (value: string | undefined) => void;
placeholder: string;
options: string[];
}
const ContextCombobox: React.FC<ContextComboboxProps> = ({ id, value, onChange, placeholder, options }) => (
<Combobox
items={options}
value={value ?? null}
onValueChange={(next: string | null) => onChange(next ?? undefined)}
filter={includesQuery}
>
<ComboboxInput id={id} placeholder={placeholder} className="w-full" showClear={Boolean(value)} />
<ComboboxContent>
<ComboboxEmpty>No options found</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item} title={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
const [form] = Form.useForm();
const form = useForm<PolicyTestFormValues>({ defaultValues: EMPTY_VALUES });
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<ResolveResult | null>(null);
const [hasSearched, setHasSearched] = useState(false);
@ -72,14 +141,7 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
setIsLoading(true);
setHasSearched(true);
try {
const values = form.getFieldsValue(true);
const context: any = {};
if (values.team_alias) context.team_alias = values.team_alias;
if (values.key_alias) context.key_alias = values.key_alias;
if (values.model) context.model = values.model;
if (values.tags && values.tags.length > 0) context.tags = values.tags;
const data = await resolvePoliciesCall(accessToken, context);
const data = await resolvePoliciesCall(accessToken, buildResolveContext(form.getValues()));
setResult(data);
} catch (error) {
console.error("Error resolving policies:", error);
@ -90,76 +152,86 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
};
const handleReset = () => {
form.resetFields();
form.reset(EMPTY_VALUES);
setResult(null);
setHasSearched(false);
};
return (
<div>
<div className="bg-white border rounded-lg p-6 mb-6">
<div className="bg-card border border-border rounded-lg p-6 mb-6">
<div className="mb-5">
<h3 className="text-base font-semibold mb-1">Policy Simulator</h3>
<Text type="secondary">
<span className="text-muted-foreground">
Simulate a request to see which policies and guardrails would apply. Select a team, key, model, or tags
below and click &quot;Simulate&quot; to see the results.
</Text>
</span>
</div>
<Form form={form} layout="vertical">
<div className="grid grid-cols-2 gap-4">
<Form.Item name="team_alias" label="Team Alias" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a team alias"
options={availableTeams.map((t) => ({ label: t, value: t }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item name="key_alias" label="Key Alias" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a key alias"
options={availableKeys.map((k) => ({ label: k, value: k }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item name="model" label="Model" className="mb-3">
<Select
showSearch
allowClear
placeholder="Select or type a model"
options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item name="tags" label="Tags" className="mb-3">
<Select
mode="tags"
placeholder="Type a tag and press Enter"
tokenSeparators={[",", " "]}
notFoundContent={null}
suffixIcon={null}
open={false}
/>
</Form.Item>
</div>
<div className="flex space-x-2">
<Button onClick={handleTest} loading={isLoading} disabled={!accessToken}>
<form onSubmit={(event) => event.preventDefault()} noValidate>
<FieldGroup className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="team_alias" label="Team Alias">
{({ id, value, onChange }) => (
<ContextCombobox
id={id}
value={value}
onChange={onChange}
placeholder="Select or type a team alias"
options={availableTeams}
/>
)}
</FormField>
<FormField control={form.control} name="key_alias" label="Key Alias">
{({ id, value, onChange }) => (
<ContextCombobox
id={id}
value={value}
onChange={onChange}
placeholder="Select or type a key alias"
options={availableKeys}
/>
)}
</FormField>
<FormField control={form.control} name="model" label="Model">
{({ id, value, onChange }) => (
<ContextCombobox
id={id}
value={value}
onChange={onChange}
placeholder="Select or type a model"
options={availableModels}
/>
)}
</FormField>
<FormField control={form.control} name="tags" label="Tags">
{({ id, value, onChange, onBlur }) => (
<TokenSelect
id={id}
value={value}
onValueChange={onChange}
onBlur={onBlur}
placeholder="Type a tag and press Enter"
allowCustomValues
tokenSeparators={[",", " "]}
/>
)}
</FormField>
</FieldGroup>
<div className="flex space-x-2 mt-4">
<Button type="button" onClick={handleTest} disabled={isLoading || !accessToken}>
{isLoading && <UiLoadingSpinner className="size-4" />}
Simulate
</Button>
<Button variant="secondary" onClick={handleReset}>
<Button type="button" variant="secondary" onClick={handleReset}>
Reset
</Button>
</div>
</Form>
</form>
</div>
{!hasSearched && (
<div className="bg-white border rounded-lg p-8 text-center">
<div className="text-gray-400 mb-2">
<div className="bg-card border border-border rounded-lg p-8 text-center">
<div className="text-muted-foreground mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10 mx-auto mb-3"
@ -175,8 +247,8 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
/>
</svg>
</div>
<p className="text-sm font-medium text-gray-600 mb-1">No simulation run yet</p>
<p className="text-xs text-gray-400">
<p className="text-sm font-medium text-foreground mb-1">No simulation run yet</p>
<p className="text-xs text-muted-foreground">
Fill in one or more fields above and click &quot;Simulate&quot; to see which policies and guardrails would
apply to that request.
</p>
@ -184,7 +256,7 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
)}
{hasSearched && result && (
<div className="bg-white border rounded-lg p-6">
<div className="bg-card border border-border rounded-lg p-6">
{result.matched_policies.length === 0 ? (
<Empty description="No policies matched this context" />
) : (
@ -194,12 +266,15 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
<div className="flex flex-wrap gap-1">
{result.effective_guardrails.length > 0 ? (
result.effective_guardrails.map((g) => (
<Tag key={g} color="green">
<Badge
key={g}
className="border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300"
>
{g}
</Tag>
</Badge>
))
) : (
<span className="text-gray-400 text-sm">None</span>
<span className="text-muted-foreground text-sm">None</span>
)}
</div>
</div>
@ -208,7 +283,7 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
<p className="text-sm font-semibold mb-2">Matched Policies</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">Policy</th>
<th className="text-left py-2 pr-4">Matched Via</th>
<th className="text-left py-2">Guardrails Added</th>
@ -216,22 +291,27 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
</thead>
<tbody>
{result.matched_policies.map((p) => (
<tr key={p.policy_name} className="border-b last:border-0">
<tr key={p.policy_name} className="border-b border-border last:border-0">
<td className="py-2 pr-4 font-medium">{p.policy_name}</td>
<td className="py-2 pr-4">
<Tag color="blue">{p.matched_via}</Tag>
<Badge className="border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300">
{p.matched_via}
</Badge>
</td>
<td className="py-2">
{p.guardrails_added.length > 0 ? (
<div className="flex flex-wrap gap-1">
{p.guardrails_added.map((g) => (
<Tag key={g} color="green">
<Badge
key={g}
className="border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300"
>
{g}
</Tag>
</Badge>
))}
</div>
) : (
<span className="text-gray-400">None</span>
<span className="text-muted-foreground">None</span>
)}
</td>
</tr>

View file

@ -0,0 +1,216 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import EditUserModal from "./edit_user";
const POSSIBLE_UI_ROLES = {
proxy_admin: { ui_label: "Admin", description: "Can create keys, teams, users" },
internal_user: { ui_label: "Internal User", description: "Can create keys for themselves" },
};
const USER = {
user_id: "user-123",
user_email: "seed@example.com",
user_role: "internal_user",
spend: 3.5,
max_budget: 10,
budget_duration: "24h",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
teams: ["team-a"],
models: ["gpt-4"],
key_count: 7,
};
const renderModal = (overrides: Partial<React.ComponentProps<typeof EditUserModal>> = {}) => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
renderWithProviders(
<EditUserModal
visible
possibleUIRoles={POSSIBLE_UI_ROLES}
onCancel={onCancel}
user={USER}
onSubmit={onSubmit}
{...overrides}
/>,
);
return { onSubmit, onCancel };
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
const buttons = screen.getAllByRole("button", { name: "Save" });
await user.click(buttons[0]);
};
describe("EditUserModal", () => {
it("renders nothing when there is no user", () => {
renderWithProviders(
<EditUserModal visible possibleUIRoles={POSSIBLE_UI_ROLES} onCancel={vi.fn()} user={null} onSubmit={vi.fn()} />,
);
expect(screen.queryByText(/Edit User/)).not.toBeInTheDocument();
});
it("titles the modal with the user id", async () => {
renderModal();
expect(await screen.findByText("Edit User user-123")).toBeInTheDocument();
});
it("submits exactly the six bound fields, seeded from the user, and drops every other user key", async () => {
const user = userEvent.setup();
const { onSubmit, onCancel } = renderModal();
await screen.findByText("Edit User user-123");
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const payload = onSubmit.mock.calls[0][0];
expect(Object.keys(payload).sort()).toEqual([
"budget_duration",
"max_budget",
"spend",
"user_email",
"user_id",
"user_role",
]);
const seededPayload = {
user_id: "user-123",
user_email: "seed@example.com",
user_role: "internal_user",
spend: 3.5,
max_budget: 10,
budget_duration: "24h",
};
expect(payload).toEqual(seededPayload);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("submits the edited email as a string", async () => {
const user = userEvent.setup();
const { onSubmit } = renderModal();
const email = await screen.findByLabelText("User Email");
await user.clear(email);
await user.type(email, "edited@example.com");
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].user_email).toBe("edited@example.com");
});
it("submits spend as a number and max_budget as a string once both are retyped", async () => {
const user = userEvent.setup();
const { onSubmit } = renderModal();
const spend = await screen.findByLabelText("Spend (USD)");
await user.clear(spend);
await user.type(spend, "42.567");
const maxBudget = screen.getByLabelText("User Budget (USD)");
await user.clear(maxBudget);
await user.type(maxBudget, "77.25");
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const payload = onSubmit.mock.calls[0][0];
expect(payload.spend).toBe(42.567);
expect(payload.max_budget).toBe("77.25");
});
it("keeps a cleared spend and a cleared max_budget distinguishable from zero", async () => {
const user = userEvent.setup();
const { onSubmit } = renderModal();
const spend = await screen.findByLabelText("Spend (USD)");
await user.clear(spend);
const maxBudget = screen.getByLabelText("User Budget (USD)");
await user.clear(maxBudget);
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const payload = onSubmit.mock.calls[0][0];
expect(payload.spend).toBeNull();
expect(payload.max_budget).toBe("");
});
it("clamps a negative spend up to the minimum on blur", async () => {
const user = userEvent.setup();
const { onSubmit } = renderModal();
const spend = await screen.findByLabelText("Spend (USD)");
await user.clear(spend);
await user.type(spend, "-5");
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].spend).toBe(0);
});
it("blocks the whole submit while max_budget is below its minimum", async () => {
const user = userEvent.setup();
const { onSubmit, onCancel } = renderModal();
const maxBudget = await screen.findByLabelText("User Budget (USD)");
await user.clear(maxBudget);
await user.type(maxBudget, "-5");
await save(user);
expect(onSubmit).not.toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
});
it("submits the selected role value, not its label", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const { onSubmit } = renderModal();
await screen.findByText("Edit User user-123");
await user.click(screen.getByLabelText("User Role"));
await user.click(await screen.findByTitle("Admin"));
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].user_role).toBe("proxy_admin");
});
it("submits the selected budget duration code", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const { onSubmit } = renderModal();
await screen.findByText("Edit User user-123");
await user.click(screen.getByLabelText("Reset Budget"));
await user.click(await screen.findByRole("option", { name: "weekly" }));
await save(user);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].budget_duration).toBe("7d");
});
it("does not submit when the user cancels", async () => {
const user = userEvent.setup();
const { onSubmit, onCancel } = renderModal();
await screen.findByText("Edit User user-123");
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onSubmit).not.toHaveBeenCalled();
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
});
it("forwards null fields from the loaded user unchanged", async () => {
const actor = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const { onSubmit } = renderModal({ user: { ...USER, spend: null, max_budget: null, budget_duration: null } });
await save(actor);
const nulledPayload = {
user_email: "seed@example.com",
user_id: "user-123",
user_role: "internal_user",
spend: null,
max_budget: null,
budget_duration: null,
};
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0]?.[0]).toEqual(nulledPayload);
});
});

View file

@ -1,110 +1,199 @@
import { useEffect } from "react";
import { TextInput, SelectItem } from "@tremor/react";
import { Button as Button2, Modal, Form, Select as Select2, InputNumber } from "antd";
import React from "react";
import { useForm } from "react-hook-form";
import { Modal } from "antd";
import { CircleHelp } from "lucide-react";
import NumericalInput from "@/components/shared/numerical_input";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface EditableUser {
user_id: string;
user_email: string;
user_role: string;
spend: number | null;
max_budget: number | null;
budget_duration: string | null;
}
interface EditUserFormValues {
user_email: string | undefined;
user_id: string | undefined;
user_role: string | undefined;
spend: number | null | undefined;
max_budget: number | string | null | undefined;
budget_duration: string | null | undefined;
}
interface EditUserModalProps {
visible: boolean;
possibleUIRoles: null | Record<string, Record<string, string>>;
onCancel: () => void;
user: any;
onSubmit: (data: any) => void;
user: EditableUser | null;
onSubmit: (data: EditUserFormValues) => void;
}
const EditUserModal: React.FC<EditUserModalProps> = ({ visible, possibleUIRoles, onCancel, user, onSubmit }) => {
const [form] = Form.useForm();
interface EditUserFormProps extends Omit<EditUserModalProps, "user"> {
user: EditableUser;
}
useEffect(() => {
form.resetFields();
}, [user]);
const SPEND_MIN = 0;
const toFormValues = (user: EditableUser): EditUserFormValues => ({
user_email: user.user_email,
user_id: user.user_id,
user_role: user.user_role,
spend: user.spend,
max_budget: user.max_budget,
budget_duration: user.budget_duration,
});
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const roleOption = (uiLabel: string, description: string): React.ReactNode => (
<div className="flex">
{uiLabel} <p className="ml-2 text-xs text-muted-foreground">{description}</p>
</div>
);
const EditUserForm: React.FC<EditUserFormProps> = ({ visible, possibleUIRoles, onCancel, user, onSubmit }) => {
const form = useForm<EditUserFormValues>({ defaultValues: toFormValues(user) });
const handleCancel = async () => {
form.resetFields();
form.reset(toFormValues(user));
onCancel();
};
const handleEditSubmit = async (formValues: Record<string, any>) => {
// Call API to update team with teamId and values
const handleEditSubmit = async (formValues: EditUserFormValues) => {
onSubmit(formValues);
form.resetFields();
form.reset(toFormValues(user));
onCancel();
};
if (!user) {
return null;
}
const clampSpendToMinimum = () => {
const spend = form.getValues("spend");
if (typeof spend === "number" && spend < SPEND_MIN) {
form.setValue("spend", SPEND_MIN);
}
};
const roleItems: Record<string, React.ReactNode> = Object.fromEntries(
Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => [
role,
roleOption(ui_label, description),
]),
);
return (
<Modal open={visible} onCancel={handleCancel} footer={null} title={"Edit User " + user.user_id} width={1000}>
<Form
form={form}
onFinish={handleEditSubmit}
initialValues={user} // Pass initial values here
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item className="mt-8" label="User Email" tooltip="Email of the User" name="user_email">
<TextInput />
</Form.Item>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleEditSubmit)}>
<FieldGroup className="mt-8">
<FormField
control={form.control}
name="user_email"
label={labelWithHint("User Email", "Email of the User")}
>
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
</FormField>
<Form.Item label="user_id" name="user_id" hidden={true}>
<TextInput />
</Form.Item>
<FormField control={form.control} name="user_role" label="User Role">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={roleItems}
value={value ?? null}
onValueChange={(role: string | null) => onChange(role ?? undefined)}
>
<SelectTrigger
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
{roleOption(ui_label, description)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<Form.Item label="User Role" name="user_role">
<Select2>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}{" "}
<p className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
{description}
</p>
</div>
</SelectItem>
))}
</Select2>
</Form.Item>
<FormField
control={form.control}
name="spend"
label={labelWithHint("Spend (USD)", "(float) - Spend of all LLM calls completed by this user")}
description="Across all keys (including keys with team_id)."
>
{({ ref, value, onChange, onBlur, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
min={SPEND_MIN}
step="any"
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
onBlur={() => {
onBlur();
clampSpendToMinimum();
}}
/>
)}
</FormField>
<Form.Item
label="Spend (USD)"
name="spend"
tooltip="(float) - Spend of all LLM calls completed by this user"
help="Across all keys (including keys with team_id)."
>
<InputNumber min={0} step={0.01} />
</Form.Item>
<FormField
control={form.control}
name="max_budget"
label={labelWithHint("User Budget (USD)", "(float) - Maximum budget of this user")}
description="Maximum budget of this user."
>
{({ ref: _ref, value, ...field }) => (
<NumericalInput {...field} min={0} step={0.01} value={value ?? ""} />
)}
</FormField>
<Form.Item
label="User Budget (USD)"
name="max_budget"
tooltip="(float) - Maximum budget of this user"
help="Maximum budget of this user."
>
<NumericalInput min={0} step={0.01} />
</Form.Item>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
</FormField>
</FieldGroup>
<Form.Item label="Reset Budget" name="budget_duration">
<BudgetDurationDropdown />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
<div className="mt-2.5 text-right">
<Button type="submit">Save</Button>
</div>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">Save</Button2>
<div className="mt-2.5 text-right">
<Button type="submit">Save</Button>
</div>
</>
</Form>
</form>
</TooltipProvider>
</Modal>
);
};
const EditUserModal: React.FC<EditUserModalProps> = ({ user, ...props }) => {
if (!user) {
return null;
}
return <EditUserForm key={user.user_id} user={user} {...props} />;
};
export default EditUserModal;