mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
5e2d6addc4
commit
2ee15a6efb
9 changed files with 1251 additions and 491 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "Simulate" 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 "Simulate" 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue