Merge pull request #37033 from BerriAI/litellm_access_group_create_rhf

refactor(ui): migrate access group create modal to RHF + zod + shadcn
This commit is contained in:
yuneng-jiang 2026-08-15 09:15:23 -07:00 • committed by GitHub
commit 759cb4f24b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 501 additions and 128 deletions

View file

@ -1,59 +0,0 @@
import React from "react";
import { Modal, Form } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
import {
useCreateAccessGroup,
AccessGroupCreateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup";
interface AccessGroupCreateModalProps {
visible: boolean;
onCancel: () => void;
onSuccess?: () => void;
}
export function AccessGroupCreateModal({ visible, onCancel, onSuccess }: AccessGroupCreateModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const createMutation = useCreateAccessGroup();
const handleOk = () => {
form
.validateFields()
.then((values) => {
const params: AccessGroupCreateParams = {
access_group_name: values.name,
description: values.description,
access_model_names: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Access group created successfully");
form.resetFields();
onSuccess?.();
onCancel();
},
});
})
.catch((info) => {});
};
return (
<Modal
title="Create Access Group"
open={visible}
onOk={handleOk}
onCancel={onCancel}
width={700}
okText="Create Group"
cancelText="Cancel"
confirmLoading={createMutation.isPending}
destroyOnClose
>
<AccessGroupBaseForm form={form} />
</Modal>
);
}

View file

@ -62,11 +62,11 @@ vi.mock("./AccessGroupsDetailsPage", () => ({
),
}));
vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({
AccessGroupCreateModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) =>
visible ? (
vi.mock("./access-group-create/AccessGroupCreateDialog", () => ({
AccessGroupCreateDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
open ? (
<div data-testid="create-access-group-modal">
<button onClick={onCancel}>Cancel</button>
<button onClick={() => onOpenChange(false)}>Cancel</button>
</div>
) : null,
}));

View file

@ -7,7 +7,7 @@ import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroupCreateDialog } from "./access-group-create/AccessGroupCreateDialog";
import { AccessGroupsTable } from "./AccessGroupsTable";
import { AccessGroup } from "./types";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -104,7 +104,7 @@ export function AccessGroupsPage() {
onDeleteClick={setGroupToDelete}
/>
<AccessGroupCreateModal visible={isCreateModalVisible} onCancel={() => setIsCreateModalVisible(false)} />
<AccessGroupCreateDialog open={isCreateModalVisible} onOpenChange={setIsCreateModalVisible} />
<DeleteResourceModal
isOpen={!!groupToDelete}

View file

@ -0,0 +1,184 @@
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";
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
vi.mock("@/components/ModelSelect/ModelSelect", () => ({
ModelSelect: ({ onChange }: { onChange: (values: string[]) => void }) => (
<button type="button" onClick={() => onChange(["gpt-5.2"])}>
set-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 { AccessGroupCreateDialog } from "./AccessGroupCreateDialog";
const Harness = ({ createAccessGroup }: { createAccessGroup: (body: unknown) => Promise<unknown> }) => {
const [open, setOpen] = React.useState(true);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
reopen
</button>
<AccessGroupCreateDialog open={open} onOpenChange={setOpen} createAccessGroup={createAccessGroup} />
</>
);
};
const renderDialog = (overrides?: { createAccessGroup?: ReturnType<typeof vi.fn> }) => {
const createAccessGroup = overrides?.createAccessGroup ?? vi.fn().mockResolvedValue({});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<Harness createAccessGroup={createAccessGroup} />
</QueryClientProvider>,
);
return { createAccessGroup };
};
describe("AccessGroupCreateDialog", () => {
it("blocks submit and shows an error when the name is missing", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog();
await user.click(screen.getByRole("button", { name: "Create Group" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Please enter the access group name");
expect(createAccessGroup).not.toHaveBeenCalled();
});
it("returns to the General Info tab when submitting an invalid form from another tab", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog();
await user.click(screen.getByRole("tab", { name: "Models" }));
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Create Group" }));
expect(await screen.findByLabelText("Group Name")).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("Please enter the access group name");
expect(createAccessGroup).not.toHaveBeenCalled();
});
it("sends only the group name for a minimal create and closes the dialog", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog();
await user.type(screen.getByLabelText("Group Name"), "prod-models");
await user.click(screen.getByRole("button", { name: "Create Group" }));
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({ access_group_name: "prod-models" });
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
});
it("maps the description and model selections into the create body", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog();
await user.type(screen.getByLabelText("Group Name"), "prod-models");
await user.type(screen.getByLabelText("Description"), "engineering access");
await user.click(screen.getByRole("tab", { name: "Models" }));
await user.click(screen.getByRole("button", { name: "set-models" }));
await user.click(screen.getByRole("button", { name: "Create Group" }));
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({
access_group_name: "prod-models",
description: "engineering access",
access_model_names: ["gpt-5.2"],
});
});
it("keeps the dialog open with the entered values when the create fails", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog({
createAccessGroup: vi.fn().mockRejectedValue(new Error("boom")),
});
await user.type(screen.getByLabelText("Group Name"), "prod-models");
await user.click(screen.getByRole("button", { name: "Create Group" }));
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText("Group Name")).toHaveValue("prod-models");
});
it("resets the form when the dialog is cancelled and reopened", async () => {
const user = userEvent.setup();
renderDialog();
await user.type(screen.getByLabelText("Group Name"), "abandoned");
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "reopen" }));
expect(screen.getByLabelText("Group Name")).toHaveValue("");
});
it("resets the form when the dialog is dismissed with Escape and reopened", async () => {
const user = userEvent.setup();
renderDialog();
await user.type(screen.getByLabelText("Group Name"), "abandoned");
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "reopen" }));
expect(screen.getByLabelText("Group Name")).toHaveValue("");
});
it("cannot be dismissed while a create is pending, then closes once on success", async () => {
const user = userEvent.setup();
let resolveCreate: (value: unknown) => void = () => {};
const createAccessGroup = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
renderDialog({ createAccessGroup });
await user.type(screen.getByLabelText("Group Name"), "prod-models");
await user.keyboard("{Enter}");
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
await user.keyboard("{Escape}");
expect(screen.getByLabelText("Group Name")).toHaveValue("prod-models");
resolveCreate({});
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
});
it("does not fire a second create while one is pending", async () => {
const user = userEvent.setup();
let resolveCreate: (value: unknown) => void = () => {};
const createAccessGroup = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
renderDialog({ createAccessGroup });
await user.type(screen.getByLabelText("Group Name"), "prod-models");
await user.keyboard("{Enter}");
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
await user.keyboard("{Enter}");
expect(createAccessGroup).toHaveBeenCalledTimes(1);
resolveCreate({});
await waitFor(() => expect(screen.queryByLabelText("Group Name")).not.toBeInTheDocument());
});
});

View file

@ -0,0 +1,244 @@
"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>
);
const defaultCreateAccessGroup = async (body: AccessGroupCreateBody): Promise<unknown> => {
const { data } = await fetchClient.POST("/v1/access_group", { body });
return data;
};
interface AccessGroupCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
createAccessGroup?: (body: AccessGroupCreateBody) => Promise<unknown>;
}
export const AccessGroupCreateDialog = ({
open,
onOpenChange,
createAccessGroup = defaultCreateAccessGroup,
}: AccessGroupCreateDialogProps) => {
const queryClient = useQueryClient();
const form = useZodForm(accessGroupCreateSchema, { 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);
onOpenChange(false);
};
const mutation = useMutation({
mutationFn: (body: AccessGroupCreateBody) => createAccessGroup(body),
onSuccess: () => {
NotificationsManager.success("Access group created successfully");
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
closeAndReset();
},
onError: (error: unknown) =>
NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to create access group"),
});
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && mutation.isPending) return;
if (!nextOpen) {
form.reset(emptyAccessGroupFormValues);
setActiveTab(GENERAL_TAB);
}
onOpenChange(nextOpen);
};
const onSubmit = form.handleSubmit(
(values) => {
if (mutation.isPending) return;
mutation.mutate(buildAccessGroupCreateBody(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>Create Access Group</DialogTitle>
</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>
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Group"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { buildAccessGroupCreateBody, emptyAccessGroupFormValues } from "./mapper";
describe("buildAccessGroupCreateBody", () => {
it("sends only the trimmed name for a minimal create", () => {
expect(buildAccessGroupCreateBody({ ...emptyAccessGroupFormValues, name: " prod models " })).toStrictEqual({
access_group_name: "prod models",
});
});
it("omits a whitespace-only description", () => {
expect(buildAccessGroupCreateBody({ ...emptyAccessGroupFormValues, name: "g", description: " " })).toStrictEqual({
access_group_name: "g",
});
});
it("maps every populated field into the request body", () => {
expect(
buildAccessGroupCreateBody({
name: "g",
description: " engineering access ",
modelIds: ["gpt-5.2"],
mcpServerIds: ["srv-1", "srv-2"],
agentIds: ["agent-1"],
}),
).toStrictEqual({
access_group_name: "g",
description: "engineering access",
access_model_names: ["gpt-5.2"],
access_mcp_server_ids: ["srv-1", "srv-2"],
access_agent_ids: ["agent-1"],
});
});
});

View file

@ -0,0 +1,21 @@
import type { components } from "@/lib/http/schema";
import type { AccessGroupCreateFormValues } from "./schema";
export type AccessGroupCreateBody = components["schemas"]["AccessGroupCreateRequest"];
export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = {
name: "",
description: "",
modelIds: [],
mcpServerIds: [],
agentIds: [],
};
export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({
access_group_name: values.name.trim(),
...(values.description.trim() !== "" && { description: values.description.trim() }),
...(values.modelIds.length > 0 && { access_model_names: values.modelIds }),
...(values.mcpServerIds.length > 0 && { access_mcp_server_ids: values.mcpServerIds }),
...(values.agentIds.length > 0 && { access_agent_ids: values.agentIds }),
});

View file

@ -0,0 +1,11 @@
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>;

View file

@ -1,63 +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 AccessGroupCreateParams {
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[];
}
// ── Fetch function ───────────────────────────────────────────────────────────
const createAccessGroup = async (
accessToken: string,
params: AccessGroupCreateParams,
): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group`;
const response = await fetch(url, {
method: "POST",
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 useCreateAccessGroup = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<AccessGroupResponse, Error, AccessGroupCreateParams>({
mutationFn: async (params) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return createAccessGroup(accessToken, params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
},
});
};