refactor(ui): migrate the teams form graph off antd Form onto react-hook-form (#37417)

* test(ui): pin the teams create and update payloads before the form migration

The teams graph (Teams.tsx, TeamInfo.tsx and the MetadataKeyValueFields child they
share) is next for the antd Form to react-hook-form migration, and its submit payload
is a function of which collapsible sections the user happened to open. Nine sections
across the two files use the shadcn Collapsible, none of them passes keepMounted, and
Base UI unmounts the closed branch, so a closed section registers nothing and its keys
never reach the request body.

That matters beyond parity. /team/update reads the body with exclude_unset, so an
omitted key is never written, while an explicitly null team member budget key reaches
clear_team_member_budget_fields and nulls max_budget, budget_duration, rpm_limit and
tpm_limit on the shared budget row. antd cannot reach that today because the field is
unregistered rather than null. A port that seeds those fields or coalesces on the way
into the payload would turn a save with the section never opened into a silent clear.

The coverage that shipped with the team modal reached one of the four gating sections
on the create side and asserted key sets rather than the request body, so a null where
antd sent undefined would have passed. These cases assert both the raw payload and its
JSON round trip with toStrictEqual, which is what separates absent from null from
undefined, and they cover every gating section on both screens.

Also pinned, because each is a live behaviour a port can quietly change:

- the create path sends max_budget, tpm_limit and rpm_limit as strings, while
  team_member_budget arrives as a number through its normalize prop
- an invalid secret manager config blocks the create with its rule message suppressed
  by the item's help prop, so nothing is shown to the user
- the disable global guardrails switch is inert for a non premium user
- a value typed into a section survives collapsing and re-expanding it

Verified by adding keepMounted to all nine panels, which is the change a porter reaches
for on noticing that fields go missing: 35 of 118 went red, including every one of these
cases. The files were restored byte identical afterwards.

No production file changes here. 145 tests pass across the three files.

* refactor(ui): migrate the teams form graph off antd Form onto react-hook-form

Teams.tsx and TeamInfo.tsx were the last large antd `Form` graph in the
dashboard. Both now use `useZodForm` + `FormField`, with the shared
`MetadataKeyValueFields` child converted to a `useFieldArray`.

antd only returns the mounted registered fields from `onFinish`, so a
closed collapsible contributed no keys at all. react-hook-form keeps
unmounted values in the store (and `shouldUnregister: true` would lose
them on re-expand), so both forms project the submitted values through
the currently mounted section list before handing them to the existing
payload builders. Closed sections therefore still produce absent keys
rather than nulls, which matters at /team/update where an explicit null
clears the shared budget row.

Widgets that had no shadcn equivalent are replaced with the existing
shared ones: SearchSelect for the organization pickers, MultiSelect for
default member models, TagsInput for guardrails/policies, and a new
GuardrailsSelect for the grouped global/other guardrail dropdown.

* refactor(ui): forward the field ref to NumericalInput in the teams forms

staging turned NumericalInput into a forwardRef, so the teams graph can stop
dropping the react-hook-form ref on the floor.

* test(ui): pin the capability gate, required rules and guardrail kill switch

A mutation run over the ported teams forms found five survivors the payload
cases did not reach: the viewPolicies gate on both forms, the team name rule
on both forms, the guardrail kill switch resync, and the number coercion on a
typed model rate limit. Six cases close them.
This commit is contained in:
yuneng-jiang 2026-08-18 22:37:04 -07:00 • committed by GitHub
parent 559310f077
commit b9a267c693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2362 additions and 1253 deletions

View file

@ -40,6 +40,7 @@ export const MODEL_SENTINEL_OPTIONS = [
const MAX_VISIBLE_MODEL_CHIPS = 5;
export interface ModelSelectProps {
id?: string;
teamID?: string;
organizationID?: string;
options?: {
@ -122,7 +123,7 @@ const filterModels = (
export const ModelSelect = (props: ModelSelectProps) => {
const anchor = useComboboxAnchor();
const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props;
const { id, teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props;
const { showAllProxyModelsOverride, includeSpecialOptions } = options || {};
const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels();
const { data: team, isLoading: isLoadingTeam } = useTeam(teamID);
@ -256,7 +257,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
</>
)}
</ComboboxValue>
<ComboboxChipsInput placeholder="Select Models" aria-label="Select Models" className="min-w-24" />
<ComboboxChipsInput id={id} placeholder="Select Models" aria-label="Select Models" className="min-w-24" />
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No models found</ComboboxEmpty>

View file

@ -1223,3 +1223,261 @@ describe("Teams - which fields reach the create payload depends on the open sect
expect(payload.team_id).toBe("tid-kept");
});
});
describe("Teams - the exact bytes the create call sends", () => {
beforeEach(() => {
vi.clearAllMocks();
can.mockReturnValue(true);
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(getDefaultTeamSettings).mockResolvedValue({ values: {} });
vi.mocked(teamCreateCall).mockResolvedValue({ team_id: "new-team-1" });
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
mockUseOrganizations.mockReturnValue({ data: null });
});
const openCreateModal = async (options?: { premiumUser?: boolean }) => {
renderWithQueryClient(
<Teams accessToken="test-token" userID="user-123" userRole="Admin" premiumUser={options?.premiumUser ?? false} />,
);
act(() => {
fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Byte Contract Team" } });
};
const submit = async () => {
const buttons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalled();
});
return vi.mocked(teamCreateCall).mock.calls[0][1] as Record<string, unknown>;
};
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
const openSection = async (title: string, mountedProbe: RegExp | string) => {
fireEvent.click(screen.getByText(title));
await waitFor(() => {
expect(screen.getAllByText(mountedProbe).length).toBeGreaterThan(0);
});
};
it("sends three keys and nothing else when every section is left closed", async () => {
await openCreateModal();
const payload = await submit();
expect(payload).toStrictEqual({
team_alias: "Byte Contract Team",
organization_id: null,
models: ["no-default-models"],
max_budget: undefined,
budget_duration: undefined,
tpm_limit: undefined,
rpm_limit: undefined,
metadata: undefined,
});
expect(wireBody(payload)).toStrictEqual({
team_alias: "Byte Contract Team",
organization_id: null,
models: ["no-default-models"],
});
});
it("keeps every newly mounted but untouched field out of the request body", async () => {
await openCreateModal();
await openSection("Additional Settings", /Team Member Key Duration/);
await openSection("MCP Settings", /Allowed MCP Servers/);
await openSection("Agent Settings", /Allowed Agents/);
await openSection("Search Tool Settings", /Allowed Search Tools/);
const payload = await submit();
expect(payload).toStrictEqual({
team_alias: "Byte Contract Team",
organization_id: null,
models: ["no-default-models"],
max_budget: undefined,
budget_duration: undefined,
tpm_limit: undefined,
rpm_limit: undefined,
metadata: undefined,
team_id: undefined,
team_member_budget: undefined,
team_member_key_duration: undefined,
team_member_rpm_limit: undefined,
team_member_tpm_limit: undefined,
secret_manager_settings: undefined,
guardrails: undefined,
disable_global_guardrails: undefined,
policies: undefined,
access_group_ids: undefined,
allowed_vector_store_ids: undefined,
allowed_passthrough_routes: undefined,
allowed_mcp_servers_and_groups: undefined,
mcp_tool_permissions: {},
allowed_agents_and_groups: undefined,
object_permission_search_tools: undefined,
});
expect(wireBody(payload)).toStrictEqual({
team_alias: "Byte Contract Team",
organization_id: null,
models: ["no-default-models"],
mcp_tool_permissions: {},
});
});
it.each([
["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]],
["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]],
["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]],
])("registers %s fields only while that one section is open", async (title, probe, keys) => {
await openCreateModal();
const closedPayload = await submit();
for (const key of keys as string[]) {
expect(closedPayload).not.toHaveProperty(key);
}
});
it("carries every typed value to the payload at the type antd sends today", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "150.75" } });
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "900" } });
fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "800" } });
await openSection("Additional Settings", /Team Member Key Duration/);
fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-1" } });
fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "12.5" } });
fireEvent.change(screen.getByLabelText(/Team Member Key Duration/), { target: { value: "30d" } });
fireEvent.change(screen.getByLabelText("Team Member RPM Limit"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("Team Member TPM Limit"), { target: { value: "8" } });
fireEvent.change(screen.getByLabelText("Secret Manager Settings"), {
target: { value: '{"namespace":"admin"}' },
});
const payload = await submit();
expect(payload.max_budget).toBe("150.75");
expect(payload.tpm_limit).toBe("900");
expect(payload.rpm_limit).toBe("800");
expect(payload.team_id).toBe("tid-1");
expect(payload.team_member_budget).toBe(12.5);
expect(payload.team_member_key_duration).toBe("30d");
expect(payload.team_member_rpm_limit).toBe("7");
expect(payload.team_member_tpm_limit).toBe("8");
expect(payload.secret_manager_settings).toStrictEqual({ namespace: "admin" });
});
it("blocks the create on an invalid secret manager config, with the rule message suppressed by help", async () => {
await openCreateModal();
await openSection("Additional Settings", /Team Member Key Duration/);
fireEvent.change(screen.getByLabelText("Secret Manager Settings"), { target: { value: " " } });
const buttons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => {
expect(screen.getByLabelText("Secret Manager Settings")).toHaveAttribute("aria-invalid", "true");
});
expect(teamCreateCall).not.toHaveBeenCalled();
expect(screen.queryByText("Please enter valid JSON")).not.toBeInTheDocument();
});
it("turns the disable-global-guardrails switch into a boolean for a premium user", async () => {
await openCreateModal({ premiumUser: true });
await openSection("Additional Settings", /Team Member Key Duration/);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[switches.length - 1]);
const payload = await submit();
expect(payload.disable_global_guardrails).toBe(true);
});
it("leaves the disable-global-guardrails switch inert for a non-premium user", async () => {
await openCreateModal();
await openSection("Additional Settings", /Team Member Key Duration/);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[switches.length - 1]);
const payload = await submit();
expect(payload.disable_global_guardrails).toBeUndefined();
});
it.each([
["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]],
["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]],
["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]],
])("adds the %s keys as soon as that one section is opened", async (title, probe, keys) => {
await openCreateModal();
await openSection(title as string, probe as RegExp);
const payload = await submit();
for (const key of keys as string[]) {
expect(payload).toHaveProperty(key);
}
});
it("leaves policies out of the request body for a caller without the viewPolicies capability", async () => {
can.mockReturnValue(false);
await openCreateModal();
await openSection("Additional Settings", /Team Member Key Duration/);
const payload = await submit();
expect(payload).toStrictEqual({
team_alias: "Byte Contract Team",
organization_id: null,
models: ["no-default-models"],
max_budget: undefined,
budget_duration: undefined,
tpm_limit: undefined,
rpm_limit: undefined,
metadata: undefined,
team_id: undefined,
team_member_budget: undefined,
team_member_key_duration: undefined,
team_member_rpm_limit: undefined,
team_member_tpm_limit: undefined,
secret_manager_settings: undefined,
guardrails: undefined,
disable_global_guardrails: undefined,
access_group_ids: undefined,
allowed_vector_store_ids: undefined,
allowed_passthrough_routes: undefined,
});
});
it("blocks the create on an empty team name and names the rule", async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
act(() => {
fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
const buttons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(buttons[buttons.length - 1]);
expect(await screen.findByText("Please input a team name")).toBeInTheDocument();
expect(teamCreateCall).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,14 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import { useZodForm } from "@/lib/forms/useZodForm";
import MetadataKeyValueFields, {
MetadataPair,
metadataObjectToPairs,
metadataPairsSchema,
metadataPairsToObject,
} from "./MetadataKeyValueFields";
@ -95,13 +97,21 @@ interface HarnessProps {
schemaLoading?: boolean;
}
const harnessSchema = z.object({ metadata: metadataPairsSchema });
const Harness: React.FC<HarnessProps> = ({ onFinish, initialMetadata, schemaFields, schemaLoading }) => {
const [form] = Form.useForm();
const form = useZodForm(harnessSchema, { defaultValues: { metadata: initialMetadata ?? [] } });
return (
<Form form={form} onFinish={onFinish} initialValues={{ metadata: initialMetadata }}>
<MetadataKeyValueFields form={form} schemaFields={schemaFields} schemaLoading={schemaLoading} />
<form onSubmit={form.handleSubmit((values) => onFinish(values))}>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}
name="metadata"
schemaFields={schemaFields}
schemaLoading={schemaLoading}
/>
<button type="submit">Save</button>
</Form>
</form>
);
};

View file

@ -1,14 +1,36 @@
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Form, FormInstance, Input, Skeleton, Space } from "antd";
import { CircleMinus, Plus } from "lucide-react";
import React, { useEffect, useRef } from "react";
import {
useFieldArray,
type Control,
type FieldArrayPath,
type FieldPath,
type FieldValues,
type UseFormGetValues,
} from "react-hook-form";
import { z } from "zod/v4";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
export interface MetadataPair {
key: string;
value: string;
}
export const metadataPairsSchema = z
.array(z.object({ key: z.string().min(1, "Missing key"), value: z.string().optional() }))
.superRefine((pairs, ctx) => {
pairs.forEach((pair, index) => {
if (pair.key && pairs.filter((other) => other.key === pair.key).length > 1) {
ctx.addIssue({ code: "custom", message: "Duplicate key", path: [index, "key"] });
}
});
});
function formatMetadataValue(value: unknown): string {
if (typeof value !== "string") {
return JSON.stringify(value) ?? "";
@ -48,87 +70,82 @@ export function metadataPairsToObject(
);
}
interface MetadataKeyValueFieldsProps {
form: FormInstance;
name?: string;
interface MetadataKeyValueFieldsProps<TFieldValues extends FieldValues> {
control: Control<TFieldValues>;
getValues: UseFormGetValues<TFieldValues>;
name: FieldArrayPath<TFieldValues>;
schemaFields?: readonly TeamMetadataField[];
schemaLoading?: boolean;
}
const MetadataKeyValueFields: React.FC<MetadataKeyValueFieldsProps> = ({
form,
name = "metadata",
const MetadataKeyValueFields = <TFieldValues extends FieldValues>({
control,
getValues,
name,
schemaFields = [],
schemaLoading = false,
}) => {
}: MetadataKeyValueFieldsProps<TFieldValues>) => {
const { fields, append, remove } = useFieldArray({ control, name });
const seededRef = useRef(false);
useEffect(() => {
if (seededRef.current || schemaLoading || schemaFields.length === 0) return;
seededRef.current = true;
const pairs: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
const pairs: (Partial<MetadataPair> | undefined)[] = getValues(name as unknown as FieldPath<TFieldValues>) ?? [];
if (!Array.isArray(pairs)) return;
const existingKeys = new Set(pairs.map((pair) => pair?.key).filter(Boolean));
const seeded = schemaFields
.filter((field) => !existingKeys.has(field.key))
.map((field) => ({ key: field.key, value: "" }));
if (seeded.length > 0) {
form.setFieldValue(name, [...pairs, ...seeded]);
append(seeded as never, { shouldFocus: false });
}
}, [form, name, schemaFields, schemaLoading]);
}, [append, getValues, name, schemaFields, schemaLoading]);
if (schemaLoading) {
return (
<div data-testid="metadata-schema-skeleton">
<Skeleton active title={false} paragraph={{ rows: 3 }} />
<div data-testid="metadata-schema-skeleton" className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
);
}
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name: fieldName, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[fieldName, "key"]}
rules={[
{ required: true, message: "Missing key" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
const dupes = all.filter((entry) => entry?.key === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate key"));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item {...restField} name={[fieldName, "value"]}>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined
aria-label="Remove key-value pair"
onClick={() => remove(fieldName)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item style={{ marginBottom: 0 }}>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
<>
{fields.map((field, index) => (
<div key={field.id} className="mb-2 flex items-start gap-2">
<FormField control={control} name={`${name}.${index}.key` as FieldPath<TFieldValues>}>
{({ ref, value, ...rest }) => (
<Input {...rest} ref={ref} value={(value as string) ?? ""} placeholder="Key" />
)}
</FormField>
<FormField control={control} name={`${name}.${index}.value` as FieldPath<TFieldValues>}>
{({ ref, value, ...rest }) => (
<Input {...rest} ref={ref} value={(value as string) ?? ""} placeholder="Value" />
)}
</FormField>
<Button
variant="ghost"
size="icon"
aria-label="Remove key-value pair"
className="mt-1 text-destructive"
onClick={() => remove(index)}
>
<CircleMinus className="size-4" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full border-dashed"
onClick={() => append({ key: "", value: "" } as never, { shouldFocus: false })}
>
<Plus className="size-4" />
Add Key-Value Pair
</Button>
</>
);
};

View file

@ -0,0 +1,34 @@
"use client";
import { CircleHelp } from "lucide-react";
import React from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const hintIconClassName = "size-3.5 shrink-0 cursor-help text-muted-foreground";
export const labelWithHint = (label: React.ReactNode, hint: React.ReactNode): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className={hintIconClassName} />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
export const labelWithDocsHint = (label: React.ReactNode, hint: React.ReactNode, href: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger
render={
<a href={href} target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()}>
<CircleHelp className={hintIconClassName} />
</a>
}
/>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);

View file

@ -0,0 +1,133 @@
"use client";
import { Globe } from "lucide-react";
import React, { useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
export interface GuardrailOption {
name: string;
disabled: boolean;
}
interface GuardrailGroup {
label: string;
icon: boolean;
items: GuardrailOption[];
}
interface GuardrailsSelectProps {
id?: string;
value: string[];
onValueChange: (value: string[]) => void;
globalGuardrails: readonly GuardrailOption[];
otherGuardrails: readonly GuardrailOption[];
globalGuardrailNames: ReadonlySet<string>;
placeholder?: string;
emptyText?: string;
}
const matchesQuery = (option: GuardrailOption, query: string): boolean =>
option.name.toLowerCase().includes(query.trim().toLowerCase());
export const GuardrailsSelect: React.FC<GuardrailsSelectProps> = ({
id,
value,
onValueChange,
globalGuardrails,
otherGuardrails,
globalGuardrailNames,
placeholder = "Select guardrails",
emptyText = "No guardrails found",
}) => {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const known = [...globalGuardrails, ...otherGuardrails];
const selected = value.map((name) => known.find((option) => option.name === name) ?? { name, disabled: false });
const grouped = globalGuardrails.length > 0 && otherGuardrails.length > 0;
const groups: GuardrailGroup[] = grouped
? [
{ label: "Global", icon: true, items: [...globalGuardrails] },
{ label: "Other", icon: false, items: [...otherGuardrails] },
]
: [{ label: "", icon: false, items: known }];
return (
<Combobox
multiple
items={groups}
value={selected}
onValueChange={(next: GuardrailOption[]) => {
setQuery("");
onValueChange(next.map((option) => option.name));
}}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(option: GuardrailOption, other: GuardrailOption) => option.name === other.name}
itemToStringLabel={(option: GuardrailOption) => option.name}
filter={matchesQuery}
openOnInputClick
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
<ComboboxValue>
{(chips: GuardrailOption[]) => (
<>
{chips.map((option) => (
<ComboboxChip key={option.name} aria-label={option.name}>
{globalGuardrailNames.has(option.name) && <Globe className="size-3" aria-label="Global guardrail" />}
{option.name}
</ComboboxChip>
))}
<ComboboxChipsInput id={id} placeholder={placeholder} className="min-w-24" aria-label={placeholder} />
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(group: GuardrailGroup) => (
<ComboboxGroup key={group.label} items={group.items}>
{group.label !== "" && (
<ComboboxLabel>
{group.icon ? <Globe className="mr-1 inline size-3" aria-hidden="true" /> : null}
{group.label}
</ComboboxLabel>
)}
<ComboboxCollection>
{(option: GuardrailOption) => (
<ComboboxItem
key={option.name}
value={option}
title={option.name}
disabled={option.disabled}
aria-label={option.name}
>
{option.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default GuardrailsSelect;

View file

@ -122,7 +122,9 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
<div>
<div data-testid="alias-editor-initial">{JSON.stringify(initialModelAliases)}</div>
<button onClick={() => onAliasUpdate({ "gpt-4o": "gpt-4" })}>Set Alias</button>
<button onClick={() => onAliasUpdate({})}>Clear Aliases</button>
<button type="button" onClick={() => onAliasUpdate({})}>
Clear Aliases
</button>
</div>
)),
}));
@ -658,7 +660,7 @@ describe("TeamInfoView", () => {
});
describe("settings and editing", () => {
const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "span" });
const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "label" });
it("should offer the policies field and load it for a caller with the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
@ -1462,8 +1464,7 @@ describe("TeamInfoView", () => {
await user.click(screen.getByLabelText(/^Guardrails/));
const listbox = await screen.findByRole("listbox", {}, { timeout: 5000 });
// eslint-disable-next-line local/no-antd-class-selectors -- antd renders group headers outside the listbox and its popup container exposes no role or accessible name
return listbox.closest(".ant-select-dropdown") as HTMLElement;
return listbox.closest('[data-slot="combobox-content"]') as HTMLElement;
};
beforeEach(() => {
@ -1660,3 +1661,310 @@ describe("TeamInfoView - which team member fields reach the update payload depen
expect(payload.object_permission).toHaveProperty("search_tools");
});
});
describe("TeamInfoView - the exact bytes the update call sends", () => {
const props = {
teamId: "123",
onUpdate: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
is_team_admin: true,
is_proxy_admin: true,
userModels: ["gpt-4"],
editTeam: false,
};
beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
});
const storedTeam = () =>
createMockTeamData({
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
team_member_budget_table: { max_budget: 42, budget_duration: "30d", tpm_limit: 11, rpm_limit: 22 },
default_team_member_models: ["gpt-4"],
object_permission: { search_tools: ["tool-a"], vector_stores: ["vs-1"] },
});
const openEditor = async (user: ReturnType<typeof userEvent.setup>) => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(storedTeam());
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record<string, unknown>;
};
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
const alwaysSent = {
team_id: "123",
team_alias: "Test Team",
models: ["gpt-4"],
tpm_limit: 1000,
rpm_limit: 1000,
model_tpm_limit: {},
model_rpm_limit: {},
max_budget: 100,
soft_budget: null,
budget_duration: "1d",
metadata: {
allowed_passthrough_routes: [],
guardrails: [],
opted_out_global_guardrails: [],
disable_global_guardrails: false,
soft_budget_alerting_emails: [],
},
access_group_ids: [],
};
const mcpPermissions = {
mcp_servers: [],
mcp_access_groups: [],
mcp_tool_permissions: {},
mcp_toolsets: [],
vector_stores: ["vs-1"],
};
it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const payload = await save(user);
expect(payload).toStrictEqual({
...alwaysSent,
team_member_budget_duration: undefined,
object_permission: mcpPermissions,
});
expect(wireBody(payload)).toStrictEqual({
...alwaysSent,
object_permission: mcpPermissions,
});
});
it("resends every stored value once both sections are opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
await screen.findByLabelText("Default Budget (USD)");
await user.click(screen.getByText("Search Tool Settings"));
await screen.findByPlaceholderText("Select search tools (optional, empty = all allowed)");
const payload = await save(user);
const expected = {
...alwaysSent,
team_member_budget_duration: "30d",
team_member_budget: 42,
team_member_tpm_limit: 11,
team_member_rpm_limit: 22,
default_team_member_models: ["gpt-4"],
object_permission: { ...mcpPermissions, search_tools: ["tool-a"] },
};
expect(payload).toStrictEqual(expected);
expect(wireBody(payload)).toStrictEqual(expected);
});
it("carries every typed value to the update payload at the type and shape antd sends today", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const alias = screen.getByLabelText("Team Name");
await user.clear(alias);
await user.type(alias, "Renamed Team");
const softBudget = screen.getByLabelText("Soft Budget (USD)");
await user.clear(softBudget);
await user.type(softBudget, "9.5");
const emails = screen.getByLabelText(/Soft Budget Alerting Emails/);
await user.clear(emails);
await user.type(emails, "a@test.com, b@test.com ");
const tpm = screen.getByLabelText("Tokens per minute Limit (TPM)");
await user.clear(tpm);
await user.type(tpm, "555");
const payload = await save(user);
expect(payload.team_alias).toBe("Renamed Team");
expect(payload.soft_budget).toBe("9.5");
expect(payload.tpm_limit).toBe("555");
expect((payload.metadata as Record<string, unknown>).soft_budget_alerting_emails).toStrictEqual([
"a@test.com",
"b@test.com",
]);
});
it("builds model_tpm_limit and model_rpm_limit from the model-specific rate limit rows", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
object_permission: { vector_stores: ["vs-1"] },
metadata: { model_tpm_limit: { "gpt-4": 30 }, model_rpm_limit: { "gpt-4": 40 } },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const payload = await save(user);
expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 40 });
});
it("keeps a team member budget edited before the section is collapsed and resends it on reopen", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
const budgetInput = await screen.findByLabelText("Default Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "77");
await user.click(screen.getByText("Team Member Settings"));
await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
await user.click(screen.getByText("Team Member Settings"));
expect(await screen.findByLabelText("Default Budget (USD)")).toHaveValue(77);
const payload = await save(user);
expect(payload.team_member_budget).toBe(77);
});
it("sends no team member key at all when the section is collapsed again after an edit", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
const budgetInput = await screen.findByLabelText("Default Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "77");
await user.click(screen.getByText("Team Member Settings"));
await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
const payload = await save(user);
expect(Object.keys(wireBody(payload)).filter((key) => key.startsWith("team_member"))).toEqual([]);
expect(wireBody(payload)).not.toHaveProperty("default_team_member_models");
});
it("puts the global guardrails back on the team when the kill switch is turned off again", async () => {
const user = userEvent.setup({ delay: null });
testQueryClient.clear();
vi.mocked(networking.getGuardrailsList).mockResolvedValue({
guardrails: [
{ guardrail_name: "always-on", litellm_params: { default_on: true } },
{ guardrail_name: "opt-in", litellm_params: { default_on: false } },
],
});
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
metadata: { guardrails: ["opt-in"], disable_global_guardrails: true },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} premiumUser={true} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
expect(screen.queryAllByLabelText("always-on")).toHaveLength(0);
await user.click(screen.getByRole("switch", { name: /Disable all global guardrails/ }));
expect(await screen.findAllByLabelText("always-on")).toHaveLength(1);
const payload = await save(user);
expect(payload.metadata).toStrictEqual(
expect.objectContaining({
guardrails: ["opt-in"],
opted_out_global_guardrails: [],
disable_global_guardrails: false,
}),
);
});
it("sends a typed model rate limit as a number", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["gpt-4"], metadata: { model_tpm_limit: { "gpt-4": 30 } } }),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const rpmInput = await screen.findByPlaceholderText("RPM Limit");
await user.clear(rpmInput);
await user.type(rpmInput, "45");
const payload = await save(user);
expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 45 });
expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
});
it("leaves stored policies out of the update body for a caller without the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
can.mockReturnValue(false);
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"], policies: ["pci"] }));
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const payload = await save(user);
expect(payload).toHaveProperty("team_alias");
expect(wireBody(payload)).not.toHaveProperty("policies");
});
it("blocks the save on an empty team name and names the rule", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.clear(screen.getByLabelText("Team Name"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("Please input a team name")).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load diff