mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
feat(access-groups): add PATCH route and migrate the edit modal to RHF + zod + shadcn
Register PATCH /v1/access_group/{id} (and the unified alias) on the existing
partial-update handler so the dashboard has a proper partial-update verb, keep
PUT for compatibility, and regenerate the lazy OpenAPI snapshot fragment plus
schema.d.ts for it.
Replace the antd Edit Access Group modal with a shadcn dialog that shares its
tabbed fields with the create dialog, hydrates from the record, and sends only
the dirty fields over PATCH.
Fix usePickDirty so every field edited before the first save is sent: RHF only
maintains dirtyFields for subscribers and formState.dirtyFields read inside a
submit handler is a stale snapshot, which also dropped the second edited field
in the organization settings form.
This commit is contained in:
parent
973329e986
commit
1e4f66f0fc
24 changed files with 1025 additions and 677 deletions
|
|
@ -782,6 +782,13 @@
|
|||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {
|
||||
"title": "Context",
|
||||
"type": "object"
|
||||
},
|
||||
"input": {
|
||||
"title": "Input"
|
||||
},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
|
|
@ -1202,6 +1209,61 @@
|
|||
"access_groups"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "update_access_group_v1_access_group__access_group_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Update Access Group",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_access_group_v1_access_group__access_group_id__put",
|
||||
"parameters": [
|
||||
|
|
@ -1416,6 +1478,61 @@
|
|||
"access_groups"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "update_access_group_v1_unified_access_group__access_group_id__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Update Access Group",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_access_group_v1_unified_access_group__access_group_id__put",
|
||||
"parameters": [
|
||||
|
|
|
|||
|
|
@ -416,6 +416,10 @@ async def get_access_group(
|
|||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/v1/access_group/{access_group_id}",
|
||||
response_model=AccessGroupResponse,
|
||||
)
|
||||
@router.put(
|
||||
"/v1/access_group/{access_group_id}",
|
||||
response_model=AccessGroupResponse,
|
||||
|
|
@ -637,6 +641,12 @@ router.add_api_route(
|
|||
methods=["PUT"],
|
||||
response_model=AccessGroupResponse,
|
||||
)
|
||||
router.add_api_route(
|
||||
"/v1/unified_access_group/{access_group_id}",
|
||||
update_access_group,
|
||||
methods=["PATCH"],
|
||||
response_model=AccessGroupResponse,
|
||||
)
|
||||
router.add_api_route(
|
||||
"/v1/unified_access_group/{access_group_id}",
|
||||
delete_access_group,
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["put", "patch"])
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
@pytest.mark.parametrize(
|
||||
"update_payload",
|
||||
|
|
@ -419,18 +420,36 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role):
|
|||
{"assigned_team_ids": [], "assigned_key_ids": ["key-1"]},
|
||||
],
|
||||
)
|
||||
def test_update_access_group_success(client_and_mocks, base_path, update_payload):
|
||||
"""Update access group with various payloads returns 200."""
|
||||
def test_update_access_group_success(client_and_mocks, method, base_path, update_payload):
|
||||
"""Update access group with various payloads returns 200 over both PUT and PATCH."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
||||
existing = _make_access_group_record(access_group_id="ag-update")
|
||||
mock_table.find_unique = AsyncMock(return_value=existing)
|
||||
|
||||
resp = client.put(f"{base_path}/ag-update", json=update_payload)
|
||||
resp = client.request(method, f"{base_path}/ag-update", json=update_payload)
|
||||
assert resp.status_code == 200
|
||||
mock_table.update.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
|
||||
def test_patch_access_group_writes_only_sent_fields(client_and_mocks, base_path):
|
||||
"""PATCH with one field leaves every other column out of the write, so untouched grants survive."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
||||
existing = _make_access_group_record(
|
||||
access_group_id="ag-update", access_model_names=["model-1"], access_agent_ids=["agent-1"]
|
||||
)
|
||||
mock_table.find_unique = AsyncMock(return_value=existing)
|
||||
|
||||
resp = client.patch(f"{base_path}/ag-update", json={"description": "Only this changes"})
|
||||
assert resp.status_code == 200
|
||||
assert mock_table.update.call_args.kwargs["data"] == {
|
||||
"updated_by": "admin_user",
|
||||
"description": "Only this changes",
|
||||
}
|
||||
|
||||
|
||||
def test_update_access_group_not_found(client_and_mocks):
|
||||
"""Update access group returns 404 when not found."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");
|
||||
vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({
|
||||
AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
|
||||
visible ? (
|
||||
vi.mock("./access-group-edit/AccessGroupEditDialog", () => ({
|
||||
AccessGroupEditDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Edit Access Group">
|
||||
<button onClick={onCancel}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
|
||||
import { AccessGroupEditDialog } from "./access-group-edit/AccessGroupEditDialog";
|
||||
|
||||
interface AccessGroupDetailProps {
|
||||
accessGroupId: string;
|
||||
|
|
@ -218,11 +218,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccessGroupEditModal
|
||||
visible={isEditModalVisible}
|
||||
accessGroup={accessGroup}
|
||||
onCancel={() => setIsEditModalVisible(false)}
|
||||
/>
|
||||
<AccessGroupEditDialog open={isEditModalVisible} onOpenChange={setIsEditModalVisible} accessGroup={accessGroup} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import type { FormInstance } from "antd";
|
||||
import { Form, Input, Select, Space, Tabs } from "antd";
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface AccessGroupFormValues {
|
||||
name: string;
|
||||
description: string;
|
||||
modelIds: string[];
|
||||
mcpServerIds: string[];
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
interface AccessGroupBaseFormProps {
|
||||
form: FormInstance<AccessGroupFormValues>;
|
||||
isNameDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGroupBaseFormProps) {
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
||||
const agents = agentsData?.agents ?? [];
|
||||
const mcpServers = mcpServersData ?? [];
|
||||
const items = [
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<InfoIcon size={16} />
|
||||
General Info
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Group Name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please enter the access group name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g. Engineering Team" disabled={isNameDisabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={4} placeholder="Describe the purpose of this access group..." />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="modelIds" label="Allowed Models">
|
||||
<ModelSelect
|
||||
context="global"
|
||||
value={form.getFieldValue("modelIds") ?? []}
|
||||
onChange={(values) => form.setFieldsValue({ modelIds: values })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="mcpServerIds" label="Allowed MCP Servers">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select MCP servers"
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={mcpServers.map((server) => ({
|
||||
label: server.server_name ?? server.server_id,
|
||||
value: server.server_id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="agentIds" label="Allowed Agents">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select agents"
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={agents.map((agent) => ({
|
||||
label: agent.agent_name,
|
||||
value: agent.agent_id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
name="access_group_form"
|
||||
initialValues={{
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
}}
|
||||
>
|
||||
<Tabs defaultActiveKey="1" items={items} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Modal, Form } from "antd";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
|
||||
import { useEditAccessGroup, AccessGroupUpdateParams } from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
|
||||
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
|
||||
interface AccessGroupEditModalProps {
|
||||
visible: boolean;
|
||||
accessGroup: AccessGroupResponse;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess }: AccessGroupEditModalProps) {
|
||||
const [form] = Form.useForm<AccessGroupFormValues>();
|
||||
const editMutation = useEditAccessGroup();
|
||||
|
||||
// Populate the form with initial values whenever the modal opens or the data changes
|
||||
useEffect(() => {
|
||||
if (visible && accessGroup) {
|
||||
form.setFieldsValue({
|
||||
name: accessGroup.access_group_name,
|
||||
description: accessGroup.description ?? "",
|
||||
modelIds: accessGroup.access_model_names ?? [],
|
||||
mcpServerIds: accessGroup.access_mcp_server_ids ?? [],
|
||||
agentIds: accessGroup.access_agent_ids ?? [],
|
||||
});
|
||||
}
|
||||
}, [visible, accessGroup, form]);
|
||||
|
||||
const handleOk = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const params: AccessGroupUpdateParams = {
|
||||
access_group_name: values.name,
|
||||
description: values.description,
|
||||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
};
|
||||
|
||||
editMutation.mutate(
|
||||
{ accessGroupId: accessGroup.access_group_id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Access group updated successfully");
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch((info) => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Edit Access Group"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={700}
|
||||
okText="Save Changes"
|
||||
cancelText="Cancel"
|
||||
confirmLoading={editMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
import { buildAccessGroupCreateBody, emptyAccessGroupFormValues, type AccessGroupCreateBody } from "./mapper";
|
||||
import { accessGroupCreateSchema } from "./schema";
|
||||
|
||||
const GENERAL_TAB = "general";
|
||||
|
||||
interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
id: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: MultiSelectOption[];
|
||||
placeholder: string;
|
||||
"aria-invalid": true | undefined;
|
||||
"aria-describedby": string | undefined;
|
||||
}
|
||||
|
||||
const MultiSelect = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}: MultiSelectProps) => (
|
||||
<Select multiple items={options} value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{(selected: string[]) =>
|
||||
selected.length === 0
|
||||
? placeholder
|
||||
: options
|
||||
.filter((option) => selected.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(", ")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
import { AccessGroupFormFields, GENERAL_TAB } from "../access-group-form/AccessGroupFormFields";
|
||||
import { accessGroupFormSchema, emptyAccessGroupFormValues } from "../access-group-form/schema";
|
||||
import { buildAccessGroupCreateBody, type AccessGroupCreateBody } from "./mapper";
|
||||
|
||||
const defaultCreateAccessGroup = async (body: AccessGroupCreateBody): Promise<unknown> => {
|
||||
const { data } = await fetchClient.POST("/v1/access_group", { body });
|
||||
|
|
@ -89,21 +31,9 @@ export const AccessGroupCreateDialog = ({
|
|||
createAccessGroup = defaultCreateAccessGroup,
|
||||
}: AccessGroupCreateDialogProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues });
|
||||
const form = useZodForm(accessGroupFormSchema, { defaultValues: emptyAccessGroupFormValues });
|
||||
const [activeTab, setActiveTab] = React.useState(GENERAL_TAB);
|
||||
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
||||
const mcpServerOptions = (mcpServersData ?? []).map((server) => ({
|
||||
value: server.server_id,
|
||||
label: server.server_name ?? server.server_id,
|
||||
}));
|
||||
const agentOptions = (agentsData?.agents ?? []).map((agent) => ({
|
||||
value: agent.agent_id,
|
||||
label: agent.agent_name,
|
||||
}));
|
||||
|
||||
const closeAndReset = () => {
|
||||
form.reset(emptyAccessGroupFormValues);
|
||||
setActiveTab(GENERAL_TAB);
|
||||
|
|
@ -147,82 +77,7 @@ export const AccessGroupCreateDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value={GENERAL_TAB}>
|
||||
<InfoIcon />
|
||||
General Info
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
<LayersIcon />
|
||||
Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers">
|
||||
<ServerIcon />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<BotIcon />
|
||||
Agents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={GENERAL_TAB} className="pt-4">
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Group Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. Engineering Team" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
rows={4}
|
||||
placeholder="Describe the purpose of this access group..."
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<FormField control={form.control} name="modelIds" label="Allowed Models">
|
||||
{(field) => <ModelSelect context="global" value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp-servers" className="pt-4">
|
||||
<FormField control={form.control} name="mcpServerIds" label="Allowed MCP Servers">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={mcpServerOptions}
|
||||
placeholder="Select MCP servers"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<FormField control={form.control} name="agentIds" label="Allowed Agents">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={agentOptions}
|
||||
placeholder="Select agents"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<AccessGroupFormFields control={form.control} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAccessGroupCreateBody, emptyAccessGroupFormValues } from "./mapper";
|
||||
import { emptyAccessGroupFormValues } from "../access-group-form/schema";
|
||||
import { buildAccessGroupCreateBody } from "./mapper";
|
||||
|
||||
describe("buildAccessGroupCreateBody", () => {
|
||||
it("sends only the trimmed name for a minimal create", () => {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
import type { AccessGroupCreateFormValues } from "./schema";
|
||||
import type { AccessGroupFormValues } from "../access-group-form/schema";
|
||||
|
||||
export type AccessGroupCreateBody = components["schemas"]["AccessGroupCreateRequest"];
|
||||
|
||||
export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
};
|
||||
|
||||
export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({
|
||||
export const buildAccessGroupCreateBody = (values: AccessGroupFormValues): AccessGroupCreateBody => ({
|
||||
access_group_name: values.name.trim(),
|
||||
...(values.description.trim() !== "" && { description: values.description.trim() }),
|
||||
...(values.modelIds.length > 0 && { access_model_names: values.modelIds }),
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const accessGroupCreateSchema = z.object({
|
||||
name: z.string().refine((value) => value.trim() !== "", "Please enter the access group name"),
|
||||
description: z.string(),
|
||||
modelIds: z.array(z.string()),
|
||||
mcpServerIds: z.array(z.string()),
|
||||
agentIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AccessGroupCreateFormValues = z.output<typeof accessGroupCreateSchema>;
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { accessGroupKeys, type AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
__esModule: true,
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/components/ModelSelect/ModelSelect", () => ({
|
||||
ModelSelect: ({ value, onChange }: { value: string[]; onChange: (values: string[]) => void }) => (
|
||||
<>
|
||||
<span data-testid="models-value">{value.join(",")}</span>
|
||||
<button type="button" onClick={() => onChange(["gpt-5.2", "claude-sonnet-5"])}>
|
||||
set-models
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange([])}>
|
||||
clear-models
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({
|
||||
useAgents: () => ({ data: { agents: [{ agent_id: "agent-1", agent_name: "Support Agent" }] } }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
|
||||
useMCPServers: () => ({ data: [{ server_id: "srv-1", server_name: "GitHub MCP" }] }),
|
||||
}));
|
||||
|
||||
import { AccessGroupEditDialog } from "./AccessGroupEditDialog";
|
||||
|
||||
const GROUP: AccessGroupResponse = {
|
||||
access_group_id: "ag-1",
|
||||
access_group_name: "prod-models",
|
||||
description: "Original description",
|
||||
access_model_names: ["gpt-5.2"],
|
||||
access_mcp_server_ids: [],
|
||||
access_agent_ids: [],
|
||||
assigned_team_ids: [],
|
||||
assigned_key_ids: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
type PatchFn = (id: string, body: unknown) => Promise<AccessGroupResponse | undefined>;
|
||||
|
||||
const Harness = ({ patchAccessGroup, group }: { patchAccessGroup: PatchFn; group: AccessGroupResponse }) => {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
reopen
|
||||
</button>
|
||||
<AccessGroupEditDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
accessGroup={group}
|
||||
patchAccessGroup={patchAccessGroup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDialog = (overrides?: { patchAccessGroup?: ReturnType<typeof vi.fn>; group?: AccessGroupResponse }) => {
|
||||
const patchAccessGroup = overrides?.patchAccessGroup ?? vi.fn().mockResolvedValue(undefined);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness patchAccessGroup={patchAccessGroup} group={overrides?.group ?? GROUP} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { patchAccessGroup, queryClient };
|
||||
};
|
||||
|
||||
const saveButton = () => screen.getByRole("button", { name: /Save Changes|Saving\.\.\./ });
|
||||
|
||||
describe("AccessGroupEditDialog", () => {
|
||||
it("hydrates the form from the access group and disables Save until something changes", () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByLabelText("Group Name")).toHaveValue("prod-models");
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("Original description");
|
||||
expect(saveButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends only the changed field and closes the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "New description");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(patchAccessGroup.mock.calls[0]).toStrictEqual(["ag-1", { description: "New description" }]);
|
||||
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sends every field edited across tabs before the first save, not just the first one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "MCP Servers" }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Allowed MCP Servers" }));
|
||||
await user.click(await screen.findByRole("option", { name: "GitHub MCP" }));
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("tab", { name: "Agents" }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Allowed Agents" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Support Agent" }));
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(patchAccessGroup.mock.calls[0][1]).toStrictEqual({
|
||||
access_mcp_server_ids: ["srv-1"],
|
||||
access_agent_ids: ["agent-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the description with null when it is blanked out", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(patchAccessGroup.mock.calls[0][1]).toStrictEqual({ description: null });
|
||||
});
|
||||
|
||||
it("does not send a field that was edited back to its original value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.type(screen.getByLabelText("Group Name"), "x");
|
||||
await user.type(screen.getByLabelText("Group Name"), "{Backspace}");
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "New description");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(patchAccessGroup.mock.calls[0][1]).toStrictEqual({ description: "New description" });
|
||||
});
|
||||
|
||||
it("sends an emptied model list as [] so the grant is removed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Models" }));
|
||||
expect(screen.getByTestId("models-value")).toHaveTextContent("gpt-5.2");
|
||||
await user.click(screen.getByRole("button", { name: "clear-models" }));
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(patchAccessGroup.mock.calls[0][1]).toStrictEqual({ access_model_names: [] });
|
||||
});
|
||||
|
||||
it("blocks submit, shows an error, and returns to General Info when the name is blanked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog();
|
||||
|
||||
await user.clear(screen.getByLabelText("Group Name"));
|
||||
await user.click(screen.getByRole("tab", { name: "Models" }));
|
||||
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
|
||||
await user.click(saveButton());
|
||||
|
||||
expect(await screen.findByLabelText("Group Name")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Please enter the access group name");
|
||||
expect(patchAccessGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the returned record into the detail cache on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
const updated: AccessGroupResponse = {
|
||||
...GROUP,
|
||||
description: "New description",
|
||||
updated_at: "2026-02-01T00:00:00Z",
|
||||
};
|
||||
const { queryClient } = renderDialog({ patchAccessGroup: vi.fn().mockResolvedValue(updated) });
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "New description");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(queryClient.getQueryData(accessGroupKeys.detail("ag-1"))).toStrictEqual(updated));
|
||||
});
|
||||
|
||||
it("keeps the dialog open with the edited values when the save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchAccessGroup } = renderDialog({ patchAccessGroup: vi.fn().mockRejectedValue(new Error("boom")) });
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "New description");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("New description");
|
||||
});
|
||||
|
||||
it("discards edits when cancelled and reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "abandoned");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByLabelText("Description")).not.toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "reopen" }));
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("Original description");
|
||||
expect(saveButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("discards edits when dismissed with Escape and reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "abandoned");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByLabelText("Description")).not.toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "reopen" }));
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("Original description");
|
||||
});
|
||||
|
||||
it("cannot be dismissed while a save is pending, then closes once on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSave: (value: AccessGroupResponse | undefined) => void = () => {};
|
||||
const patchAccessGroup = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
renderDialog({ patchAccessGroup });
|
||||
|
||||
await user.clear(screen.getByLabelText("Description"));
|
||||
await user.type(screen.getByLabelText("Description"), "New description");
|
||||
await user.type(screen.getByLabelText("Group Name"), "{Enter}");
|
||||
await waitFor(() => expect(patchAccessGroup).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByLabelText("Description")).toHaveValue("New description");
|
||||
|
||||
await user.type(screen.getByLabelText("Group Name"), "{Enter}");
|
||||
expect(patchAccessGroup).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveSave(undefined);
|
||||
await waitFor(() => expect(screen.queryByLabelText("Description")).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { accessGroupKeys, type AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { usePickDirty } from "@/lib/forms/pickDirty";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
import { AccessGroupFormFields, GENERAL_TAB } from "../access-group-form/AccessGroupFormFields";
|
||||
import { accessGroupFormSchema } from "../access-group-form/schema";
|
||||
import { buildAccessGroupPatchBody, formValuesFromAccessGroup, type AccessGroupPatchBody } from "./mapper";
|
||||
|
||||
const defaultPatchAccessGroup = async (
|
||||
accessGroupId: string,
|
||||
body: AccessGroupPatchBody,
|
||||
): Promise<AccessGroupResponse | undefined> => {
|
||||
const { data } = await fetchClient.PATCH("/v1/access_group/{access_group_id}", {
|
||||
params: { path: { access_group_id: accessGroupId } },
|
||||
body,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
interface AccessGroupEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
accessGroup: AccessGroupResponse;
|
||||
patchAccessGroup?: (accessGroupId: string, body: AccessGroupPatchBody) => Promise<AccessGroupResponse | undefined>;
|
||||
}
|
||||
|
||||
export const AccessGroupEditDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
accessGroup,
|
||||
patchAccessGroup = defaultPatchAccessGroup,
|
||||
}: AccessGroupEditDialogProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const initialValues = React.useMemo(() => formValuesFromAccessGroup(accessGroup), [accessGroup]);
|
||||
const form = useZodForm(accessGroupFormSchema, {
|
||||
values: initialValues,
|
||||
resetOptions: { keepDirtyValues: true },
|
||||
});
|
||||
const pickDirty = usePickDirty(form.control);
|
||||
const [activeTab, setActiveTab] = React.useState(GENERAL_TAB);
|
||||
|
||||
// reset() inherits resetOptions, so keepDirtyValues must be turned off to actually drop the edits
|
||||
const discardEdits = () => form.reset(initialValues, { keepDirtyValues: false });
|
||||
|
||||
const closeAndReset = () => {
|
||||
discardEdits();
|
||||
setActiveTab(GENERAL_TAB);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: AccessGroupPatchBody) => patchAccessGroup(accessGroup.access_group_id, body),
|
||||
onSuccess: (updated) => {
|
||||
NotificationsManager.success("Access group updated successfully");
|
||||
if (updated) {
|
||||
queryClient.setQueryData(accessGroupKeys.detail(accessGroup.access_group_id), updated);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
|
||||
closeAndReset();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to update access group"),
|
||||
});
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen && mutation.isPending) return;
|
||||
if (!nextOpen) {
|
||||
discardEdits();
|
||||
setActiveTab(GENERAL_TAB);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
(values) => {
|
||||
if (mutation.isPending) return;
|
||||
mutation.mutate(buildAccessGroupPatchBody(pickDirty(values)));
|
||||
},
|
||||
// the only validated field (name) lives on the General Info tab
|
||||
() => setActiveTab(GENERAL_TAB),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Access Group</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<AccessGroupFormFields control={form.control} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending || !form.formState.isDirty}>
|
||||
{mutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
|
||||
import { buildAccessGroupPatchBody, formValuesFromAccessGroup } from "./mapper";
|
||||
|
||||
const GROUP: AccessGroupResponse = {
|
||||
access_group_id: "ag-1",
|
||||
access_group_name: "prod-models",
|
||||
description: null,
|
||||
access_model_names: ["gpt-5.2"],
|
||||
access_mcp_server_ids: [],
|
||||
access_agent_ids: ["agent-1"],
|
||||
assigned_team_ids: [],
|
||||
assigned_key_ids: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
describe("formValuesFromAccessGroup", () => {
|
||||
it("hydrates every field and turns a null description into an empty string", () => {
|
||||
const expected = {
|
||||
name: "prod-models",
|
||||
description: "",
|
||||
modelIds: ["gpt-5.2"],
|
||||
mcpServerIds: [],
|
||||
agentIds: ["agent-1"],
|
||||
};
|
||||
expect(formValuesFromAccessGroup(GROUP)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAccessGroupPatchBody", () => {
|
||||
it("sends nothing when nothing is dirty", () => {
|
||||
expect(buildAccessGroupPatchBody({})).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("maps only the dirty fields", () => {
|
||||
expect(buildAccessGroupPatchBody({ name: " renamed ", agentIds: [] })).toStrictEqual({
|
||||
access_group_name: "renamed",
|
||||
access_agent_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the description with null when it is blanked out", () => {
|
||||
expect(buildAccessGroupPatchBody({ description: " " })).toStrictEqual({ description: null });
|
||||
});
|
||||
|
||||
it("sends an emptied list as [] so the grant is removed rather than left untouched", () => {
|
||||
expect(buildAccessGroupPatchBody({ modelIds: [], mcpServerIds: ["srv-1"] })).toStrictEqual({
|
||||
access_model_names: [],
|
||||
access_mcp_server_ids: ["srv-1"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
import type { AccessGroupFormValues } from "../access-group-form/schema";
|
||||
|
||||
export type AccessGroupPatchBody = components["schemas"]["AccessGroupUpdateRequest"];
|
||||
|
||||
export const formValuesFromAccessGroup = (group: AccessGroupResponse): AccessGroupFormValues => ({
|
||||
name: group.access_group_name,
|
||||
description: group.description ?? "",
|
||||
modelIds: group.access_model_names ?? [],
|
||||
mcpServerIds: group.access_mcp_server_ids ?? [],
|
||||
agentIds: group.access_agent_ids ?? [],
|
||||
});
|
||||
|
||||
// The endpoint writes exactly the keys it receives, so only dirty fields are mapped and a blank description clears
|
||||
export const buildAccessGroupPatchBody = (dirty: Partial<AccessGroupFormValues>): AccessGroupPatchBody => ({
|
||||
...(dirty.name !== undefined && { access_group_name: dirty.name.trim() }),
|
||||
...(dirty.description !== undefined && {
|
||||
description: dirty.description.trim() === "" ? null : dirty.description.trim(),
|
||||
}),
|
||||
...(dirty.modelIds !== undefined && { access_model_names: dirty.modelIds }),
|
||||
...(dirty.mcpServerIds !== undefined && { access_mcp_server_ids: dirty.mcpServerIds }),
|
||||
...(dirty.agentIds !== undefined && { access_agent_ids: dirty.agentIds }),
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
"use client";
|
||||
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import type { AccessGroupFormValues } from "./schema";
|
||||
|
||||
export const GENERAL_TAB = "general";
|
||||
|
||||
interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
id: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
options: MultiSelectOption[];
|
||||
placeholder: string;
|
||||
"aria-invalid": true | undefined;
|
||||
"aria-describedby": string | undefined;
|
||||
}
|
||||
|
||||
const MultiSelect = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}: MultiSelectProps) => (
|
||||
<Select multiple items={options} value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{(selected: string[]) =>
|
||||
selected.length === 0
|
||||
? placeholder
|
||||
: options
|
||||
.filter((option) => selected.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(", ")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
interface AccessGroupFormFieldsProps {
|
||||
control: Control<AccessGroupFormValues>;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const AccessGroupFormFields = ({ control, activeTab, onTabChange }: AccessGroupFormFieldsProps) => {
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
||||
const mcpServerOptions = (mcpServersData ?? []).map((server) => ({
|
||||
value: server.server_id,
|
||||
label: server.server_name ?? server.server_id,
|
||||
}));
|
||||
const agentOptions = (agentsData?.agents ?? []).map((agent) => ({
|
||||
value: agent.agent_id,
|
||||
label: agent.agent_name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value={GENERAL_TAB}>
|
||||
<InfoIcon />
|
||||
General Info
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
<LayersIcon />
|
||||
Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers">
|
||||
<ServerIcon />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<BotIcon />
|
||||
Agents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={GENERAL_TAB} className="pt-4">
|
||||
<FieldGroup>
|
||||
<FormField control={control} name="name" label="Group Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. Engineering Team" />}
|
||||
</FormField>
|
||||
<FormField control={control} name="description" label="Description">
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder="Describe the purpose of this access group..." />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<FormField control={control} name="modelIds" label="Allowed Models">
|
||||
{(field) => <ModelSelect context="global" value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp-servers" className="pt-4">
|
||||
<FormField control={control} name="mcpServerIds" label="Allowed MCP Servers">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={mcpServerOptions}
|
||||
placeholder="Select MCP servers"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<FormField control={control} name="agentIds" label="Allowed Agents">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={agentOptions}
|
||||
placeholder="Select agents"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const accessGroupFormSchema = z.object({
|
||||
name: z.string().refine((value) => value.trim() !== "", "Please enter the access group name"),
|
||||
description: z.string(),
|
||||
modelIds: z.array(z.string()),
|
||||
mcpServerIds: z.array(z.string()),
|
||||
agentIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AccessGroupFormValues = z.output<typeof accessGroupFormSchema>;
|
||||
|
||||
export const emptyAccessGroupFormValues: AccessGroupFormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
};
|
||||
|
|
@ -3,23 +3,11 @@ import { createQueryKeys } from "../common/queryKeysFactory";
|
|||
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccessGroupResponse {
|
||||
access_group_id: string;
|
||||
access_group_name: string;
|
||||
description: string | null;
|
||||
access_model_names: string[];
|
||||
access_mcp_server_ids: string[];
|
||||
access_agent_ids: string[];
|
||||
assigned_team_ids: string[];
|
||||
assigned_key_ids: string[];
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
updated_at: string;
|
||||
updated_by: string | null;
|
||||
}
|
||||
export type AccessGroupResponse = components["schemas"]["AccessGroupResponse"];
|
||||
|
||||
// ── Query keys (shared across access-group hooks) ────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccessGroupUpdateParams {
|
||||
access_group_name?: string;
|
||||
description?: string | null;
|
||||
access_model_names?: string[];
|
||||
access_mcp_server_ids?: string[];
|
||||
access_agent_ids?: string[];
|
||||
assigned_team_ids?: string[];
|
||||
assigned_key_ids?: string[];
|
||||
}
|
||||
|
||||
export interface EditAccessGroupVariables {
|
||||
accessGroupId: string;
|
||||
params: AccessGroupUpdateParams;
|
||||
}
|
||||
|
||||
// ── Fetch function ───────────────────────────────────────────────────────────
|
||||
|
||||
const updateAccessGroup = async (
|
||||
accessToken: string,
|
||||
accessGroupId: string,
|
||||
params: AccessGroupUpdateParams,
|
||||
): Promise<AccessGroupResponse> => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useEditAccessGroup = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<AccessGroupResponse, Error, EditAccessGroupVariables>({
|
||||
mutationFn: async ({ accessGroupId, params }) => {
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required");
|
||||
}
|
||||
return updateAccessGroup(accessToken, accessGroupId, params);
|
||||
},
|
||||
onSuccess: (_data, { accessGroupId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessGroupKeys.detail(accessGroupId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -113,6 +113,19 @@ describe("OrgSettingsForm", () => {
|
|||
expect(patchOrganization).toHaveBeenCalledWith("org-1", { organization_alias: "acme-2" });
|
||||
});
|
||||
|
||||
it("sends every field edited before the first save, not just the first one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchOrganization } = renderForm();
|
||||
|
||||
await user.clear(screen.getByLabelText("Organization Name"));
|
||||
await user.type(screen.getByLabelText("Organization Name"), "acme-2");
|
||||
await user.click(screen.getByRole("button", { name: "clear-models" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => expect(patchOrganization).toHaveBeenCalledTimes(1));
|
||||
expect(patchOrganization).toHaveBeenCalledWith("org-1", { organization_alias: "acme-2", models: [] });
|
||||
});
|
||||
|
||||
it("saves a sub-cent max budget the browser would veto under a 0.01 step", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { patchOrganization } = renderForm();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
import { pickDirty } from "@/lib/forms/pickDirty";
|
||||
import { usePickDirty } from "@/lib/forms/pickDirty";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
|
||||
|
|
@ -59,6 +59,7 @@ export const OrgSettingsForm = ({
|
|||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(orgSettingsSchema, { defaultValues: orgToForm(org) });
|
||||
const { isDirty } = form.formState;
|
||||
const pickDirty = usePickDirty(form.control);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: OrgPatchBody) => patchOrganization(organizationId, body),
|
||||
|
|
@ -74,7 +75,7 @@ export const OrgSettingsForm = ({
|
|||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(buildOrgPatch(pickDirty(values, form.formState.dirtyFields)));
|
||||
mutation.mutate(buildOrgPatch(pickDirty(values)));
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,154 +1,117 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { FieldValues, FormState } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pickDirty } from "./pickDirty";
|
||||
import { usePickDirty } from "./pickDirty";
|
||||
|
||||
const dirty = <T extends FieldValues>(map: Record<string, unknown>) => map as FormState<T>["dirtyFields"];
|
||||
const defaultValues = {
|
||||
team_alias: "team-a",
|
||||
max_budget: 10 as number | null,
|
||||
models: ["gpt-4", "opus"] as string[],
|
||||
model_aliases: { fast: "gpt-4" } as Record<string, string>,
|
||||
object_permission: { vector_stores: ["vs-1"] as string[] },
|
||||
modelLimits: [{ model: "gpt-4", tpm: 1 }],
|
||||
};
|
||||
|
||||
describe("pickDirty", () => {
|
||||
it("omits untouched keys entirely rather than sending them as undefined", () => {
|
||||
const result = pickDirty({ team_alias: "a", tpm_limit: 5 }, dirty({ team_alias: true }));
|
||||
|
||||
expect(result).toEqual({ team_alias: "a" });
|
||||
expect("tpm_limit" in result).toBe(false);
|
||||
// Nothing here reads formState during render on purpose: the hook must own the dirty-state subscription itself
|
||||
const renderForm = () =>
|
||||
renderHook(() => {
|
||||
const form = useForm({ defaultValues });
|
||||
const fieldArray = useFieldArray({ control: form.control, name: "modelLimits" });
|
||||
const pickDirty = usePickDirty(form.control);
|
||||
return { form, fieldArray, pickDirty };
|
||||
});
|
||||
|
||||
it("returns an empty patch when nothing is dirty", () => {
|
||||
expect(pickDirty({ team_alias: "a", models: ["gpt-4"] }, dirty({}))).toEqual({});
|
||||
});
|
||||
|
||||
describe("clear tokens survive", () => {
|
||||
it.each([
|
||||
["null scalar", null],
|
||||
["empty string", ""],
|
||||
["zero", 0],
|
||||
["false", false],
|
||||
])("keeps a dirty key whose value is %s", (_label, value) => {
|
||||
const result = pickDirty({ max_budget: value }, dirty({ max_budget: true }));
|
||||
|
||||
expect("max_budget" in result).toBe(true);
|
||||
expect(result.max_budget).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps a dirty empty array, which is how lists are cleared", () => {
|
||||
expect(pickDirty({ models: [] }, dirty({ models: true }))).toEqual({ models: [] });
|
||||
});
|
||||
|
||||
it("keeps a dirty empty object, which is how model_aliases is cleared", () => {
|
||||
expect(pickDirty({ model_aliases: {} }, dirty({ model_aliases: true }))).toEqual({ model_aliases: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirtiness is read at the top level", () => {
|
||||
it("sends the whole array when any element is dirty", () => {
|
||||
const values = { models: ["gpt-4", "gpt-5", "opus"] };
|
||||
|
||||
expect(pickDirty(values, dirty({ models: [false, true, false] }))).toEqual(values);
|
||||
});
|
||||
|
||||
it("omits the array when no element is dirty", () => {
|
||||
const result = pickDirty({ models: ["gpt-4"] }, dirty({ models: [false, false] }));
|
||||
|
||||
expect("models" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the whole object when one nested leaf is dirty", () => {
|
||||
const values = { object_permission: { vector_stores: ["vs-1"], agents: ["a-1"] } };
|
||||
|
||||
expect(pickDirty(values, dirty({ object_permission: { vector_stores: true, agents: false } }))).toEqual(values);
|
||||
});
|
||||
|
||||
it("tolerates the null holes RHF leaves in sparse per-leaf dirty arrays", () => {
|
||||
const values = {
|
||||
modelLimits: [
|
||||
{ model: "gpt-4", tpm: 1 },
|
||||
{ model: "opus", tpm: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(pickDirty(values, dirty({ modelLimits: [null, { tpm: true }] }))).toEqual(values);
|
||||
});
|
||||
|
||||
it("omits an object whose every nested leaf is clean", () => {
|
||||
const result = pickDirty(
|
||||
{ object_permission: { vector_stores: ["vs-1"] } },
|
||||
dirty({ object_permission: { vector_stores: false } }),
|
||||
);
|
||||
|
||||
expect("object_permission" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores dirty keys that are absent from the submitted values", () => {
|
||||
const result = pickDirty({ team_alias: "a" }, dirty({ team_alias: true, ghost_field: true }));
|
||||
|
||||
expect(result).toEqual({ team_alias: "a" });
|
||||
expect("ghost_field" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mutate its inputs", () => {
|
||||
const values = { models: ["gpt-4"], tpm_limit: 5 };
|
||||
const dirtyFields = dirty<typeof values>({ models: [true] });
|
||||
|
||||
pickDirty(values, dirtyFields);
|
||||
|
||||
expect(values).toEqual({ models: ["gpt-4"], tpm_limit: 5 });
|
||||
expect(dirtyFields).toEqual({ models: [true] });
|
||||
});
|
||||
|
||||
it("keeps value identity so nested references are not cloned", () => {
|
||||
const models = ["gpt-4"];
|
||||
|
||||
expect(pickDirty({ models }, dirty({ models: true })).models).toBe(models);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDirty against a real react-hook-form instance", () => {
|
||||
const defaultValues = {
|
||||
team_alias: "team-a",
|
||||
max_budget: 10 as number | null,
|
||||
models: ["gpt-4", "opus"] as string[],
|
||||
object_permission: { vector_stores: ["vs-1"] as string[] },
|
||||
modelLimits: [{ model: "gpt-4", tpm: 1 }],
|
||||
};
|
||||
|
||||
const renderForm = () =>
|
||||
renderHook(() => {
|
||||
const form = useForm({ defaultValues });
|
||||
const fieldArray = useFieldArray({ control: form.control, name: "modelLimits" });
|
||||
void form.formState.dirtyFields;
|
||||
return { form, fieldArray };
|
||||
});
|
||||
|
||||
const patchOf = (result: { current: { form: ReturnType<typeof useForm<typeof defaultValues>> } }) =>
|
||||
pickDirty(result.current.form.getValues(), result.current.form.formState.dirtyFields);
|
||||
const patchOf = (result: ReturnType<typeof renderForm>["result"]) =>
|
||||
result.current.pickDirty(result.current.form.getValues());
|
||||
|
||||
describe("usePickDirty", () => {
|
||||
it("sends nothing when the user opens the form and saves without editing", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
expect(patchOf(result)).toEqual({});
|
||||
});
|
||||
|
||||
it("sends only the edited scalar", () => {
|
||||
it("omits untouched keys entirely rather than sending them as undefined", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("team_alias", "team-b", { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ team_alias: "team-b" });
|
||||
const patch = patchOf(result);
|
||||
expect(patch).toEqual({ team_alias: "team-b" });
|
||||
expect("max_budget" in patch).toBe(false);
|
||||
});
|
||||
|
||||
it("sends null to clear a scalar, and nothing else", () => {
|
||||
it("sends every edited field, not just the one that first made the form dirty", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("team_alias", "team-b", { shouldDirty: true });
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.setValue("models", [], { shouldDirty: true });
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.setValue("max_budget", null, { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ max_budget: null });
|
||||
expect(patchOf(result)).toEqual({ team_alias: "team-b", models: [], max_budget: null });
|
||||
});
|
||||
|
||||
describe("clear tokens survive", () => {
|
||||
it("keeps null, which is how a scalar is cleared", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("max_budget", null, { shouldDirty: true });
|
||||
});
|
||||
|
||||
const patch = patchOf(result);
|
||||
expect("max_budget" in patch).toBe(true);
|
||||
expect(patch.max_budget).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an empty string", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("team_alias", "", { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ team_alias: "" });
|
||||
});
|
||||
|
||||
it("keeps zero", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("max_budget", 0, { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ max_budget: 0 });
|
||||
});
|
||||
|
||||
it("keeps an empty array, which is how lists are cleared", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("models", [], { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ models: [] });
|
||||
});
|
||||
|
||||
it("keeps an empty object, which is how model_aliases is cleared", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("model_aliases", {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ model_aliases: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it("sends an empty array to clear a list emptied through useFieldArray", () => {
|
||||
|
|
@ -161,6 +124,21 @@ describe("pickDirty against a real react-hook-form instance", () => {
|
|||
expect(patchOf(result)).toEqual({ modelLimits: [] });
|
||||
});
|
||||
|
||||
it("sends the whole array when one element of a field array changes", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.fieldArray.append({ model: "opus", tpm: 2 });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({
|
||||
modelLimits: [
|
||||
{ model: "gpt-4", tpm: 1 },
|
||||
{ model: "opus", tpm: 2 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the whole nested object when one leaf under it changes", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
|
|
@ -184,6 +162,22 @@ describe("pickDirty against a real react-hook-form instance", () => {
|
|||
expect(patchOf(result)).toEqual({});
|
||||
});
|
||||
|
||||
it("drops a list the user edited and then reverted while another field stays dirty", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("team_alias", "team-b", { shouldDirty: true });
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.setValue("models", ["gpt-4"], { shouldDirty: true });
|
||||
});
|
||||
act(() => {
|
||||
result.current.form.setValue("models", ["gpt-4", "opus"], { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ team_alias: "team-b" });
|
||||
});
|
||||
|
||||
it("resets to a clean baseline after a successful save", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
|
|
@ -196,68 +190,15 @@ describe("pickDirty against a real react-hook-form instance", () => {
|
|||
|
||||
expect(patchOf(result)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDirty picks up a pure reorder", () => {
|
||||
// RHF compares each element to its default positionally by value, not by
|
||||
// identity, so any reorder that changes the value at some index marks that
|
||||
// index dirty and the whole array is sent. A reorder that leaves every index
|
||||
// equal to its default is a value-level no-op whose payload is unchanged, so
|
||||
// omitting it is correct.
|
||||
const renderRows = (rows: Array<{ v: string }>) =>
|
||||
renderHook(() => {
|
||||
const form = useForm({ defaultValues: { rows } });
|
||||
const fieldArray = useFieldArray({ control: form.control, name: "rows" });
|
||||
void form.formState.dirtyFields;
|
||||
return { form, fieldArray };
|
||||
});
|
||||
|
||||
const patchOf = (result: { current: { form: ReturnType<typeof useForm<{ rows: Array<{ v: string }> }>> } }) =>
|
||||
pickDirty(result.current.form.getValues(), result.current.form.formState.dirtyFields);
|
||||
|
||||
it("sends the whole array after useFieldArray.move()", () => {
|
||||
const { result } = renderRows([{ v: "a" }, { v: "b" }, { v: "c" }]);
|
||||
it("keeps value identity so nested references are not cloned", () => {
|
||||
const { result } = renderForm();
|
||||
|
||||
act(() => {
|
||||
result.current.fieldArray.move(0, 2);
|
||||
result.current.form.setValue("models", ["opus"], { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ rows: [{ v: "b" }, { v: "c" }, { v: "a" }] });
|
||||
});
|
||||
|
||||
it("sends the whole array after useFieldArray.swap()", () => {
|
||||
const { result } = renderRows([{ v: "a" }, { v: "b" }, { v: "c" }]);
|
||||
|
||||
act(() => {
|
||||
result.current.fieldArray.swap(0, 2);
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({ rows: [{ v: "c" }, { v: "b" }, { v: "a" }] });
|
||||
});
|
||||
|
||||
it("sends a reordered scalar array set through setValue", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const form = useForm({ defaultValues: { models: ["a", "b", "c"] } });
|
||||
void form.formState.dirtyFields;
|
||||
return form;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setValue("models", ["c", "b", "a"], { shouldDirty: true });
|
||||
});
|
||||
|
||||
expect(pickDirty(result.current.getValues(), result.current.formState.dirtyFields)).toEqual({
|
||||
models: ["c", "b", "a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a swap of two equal elements, which is a value-level no-op", () => {
|
||||
const { result } = renderRows([{ v: "a" }, { v: "b" }, { v: "a" }]);
|
||||
|
||||
act(() => {
|
||||
result.current.fieldArray.swap(0, 2);
|
||||
});
|
||||
|
||||
expect(patchOf(result)).toEqual({});
|
||||
const values = result.current.form.getValues();
|
||||
expect(result.current.pickDirty(values).models).toBe(values.models);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FieldValues, FormState } from "react-hook-form";
|
||||
import type { Control, FieldValues, FormState } from "react-hook-form";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
const isDirtyNode = (node: unknown): boolean => {
|
||||
if (typeof node === "boolean") {
|
||||
|
|
@ -13,7 +14,7 @@ const isDirtyNode = (node: unknown): boolean => {
|
|||
return false;
|
||||
};
|
||||
|
||||
export const pickDirty = <TValues extends FieldValues>(
|
||||
const pickDirtyFields = <TValues extends FieldValues>(
|
||||
values: TValues,
|
||||
dirtyFields: FormState<TValues>["dirtyFields"],
|
||||
): Partial<TValues> =>
|
||||
|
|
@ -22,3 +23,12 @@ export const pickDirty = <TValues extends FieldValues>(
|
|||
.filter((key) => isDirtyNode((dirtyFields as Record<string, unknown>)[key]))
|
||||
.map((key) => [key, values[key]]),
|
||||
) as Partial<TValues>;
|
||||
|
||||
// RHF only keeps dirtyFields current for subscribers, and formState.dirtyFields read inside a submit handler is a stale
|
||||
// snapshot; reading it here during render is what turns the subscription on
|
||||
export const usePickDirty = <TValues extends FieldValues>(
|
||||
control: Control<TValues>,
|
||||
): ((values: TValues) => Partial<TValues>) => {
|
||||
const { dirtyFields } = useFormState({ control });
|
||||
return (values) => pickDirtyFields(values, dirtyFields);
|
||||
};
|
||||
|
|
|
|||
76
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
76
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -15725,7 +15725,8 @@ export interface paths {
|
|||
delete: operations["delete_access_group_v1_access_group__access_group_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Access Group */
|
||||
patch: operations["update_access_group_v1_access_group__access_group_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/agents": {
|
||||
|
|
@ -18741,7 +18742,8 @@ export interface paths {
|
|||
delete: operations["delete_access_group_v1_unified_access_group__access_group_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Access Group */
|
||||
patch: operations["update_access_group_v1_unified_access_group__access_group_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/vector_store/list": {
|
||||
|
|
@ -55437,6 +55439,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
update_access_group_v1_access_group__access_group_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_agents_v1_agents_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -59618,6 +59655,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
update_access_group_v1_unified_access_group__access_group_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_vector_stores_v1_vector_store_list_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue