mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
commit
759cb4f24b
9 changed files with 501 additions and 128 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue