From e09bbe9a14207a048ed90c7e1b02c84fdc472c8b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 09:05:53 -0700 Subject: [PATCH] refactor(ui): migrate tag and memory forms to react-hook-form and shadcn (#37266) Moves CreateTagModal, the tag detail editor and MemoryEditModal off antd Form onto react-hook-form with the shadcn field kit, and onto semantic colour tokens so the pages follow the dashboard theme. Submitted payloads are unchanged and pinned by tests. The collapsible budget sections keep values typed into them when collapsed and reopened, matching what antd's store did, rather than reverting to the seeded default. --- .../MemoryEditModal.integration.test.tsx | 119 ++++++++ .../memory/_components/MemoryEditModal.tsx | 133 +++++---- .../_components/components/CreateTagModal.tsx | 222 ++++++++------ .../_components/tag_info.integration.test.tsx | 144 +++++++++ .../tag-management/_components/tag_info.tsx | 275 ++++++++++-------- 5 files changed, 632 insertions(+), 261 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.integration.test.tsx new file mode 100644 index 00000000000..e7288c86d5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.integration.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MemoryRow } from "@/components/networking"; + +import { MemoryEditModal } from "./MemoryEditModal"; + +const onSave = vi.fn<(key: string, value: string, metadataText: string, isCreate: boolean) => Promise>(); +const onClose = vi.fn(); + +const existingRow: MemoryRow = { + memory_id: "mem-1", + key: "user:profile", + value: "The user prefers concise answers.", + metadata: { tags: ["example"] }, +}; + +const renderModal = (props: Partial> = {}) => + render(); + +const fill = async (user: ReturnType, label: RegExp, text: string) => { + await user.click(screen.getByLabelText(label)); + await user.paste(text); +}; + +describe("MemoryEditModal payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + onSave.mockResolvedValue(true); + }); + + it("sends the trimmed key, the value and the raw metadata text on create", async () => { + const user = userEvent.setup(); + renderModal(); + + await fill(user, /^Key/, " user_role "); + await fill(user, /^Value/, "Remembers the user is an admin"); + await fill(user, /^Metadata/, '{"tags": ["example"]}'); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith("user_role", "Remembers the user is an admin", '{"tags": ["example"]}', true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("sends an empty string for metadata the user never typed into", async () => { + const user = userEvent.setup(); + renderModal(); + + await fill(user, /^Key/, "user_role"); + await fill(user, /^Value/, "Remembers the user is an admin"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(onSave).toHaveBeenCalledWith("user_role", "Remembers the user is an admin", "", true); + }); + + it("prefills from the row and sends the edited value with isCreate false", async () => { + const user = userEvent.setup(); + renderModal({ mode: "edit", initialRow: existingRow }); + + expect(await screen.findByLabelText(/^Key/)).toHaveValue("user:profile"); + expect(screen.getByLabelText(/^Key/)).toBeDisabled(); + expect(screen.getByLabelText(/^Value/)).toHaveValue("The user prefers concise answers."); + expect(screen.getByLabelText(/^Metadata/)).toHaveValue('{\n "tags": [\n "example"\n ]\n}'); + + await user.clear(screen.getByLabelText(/^Value/)); + await fill(user, /^Value/, "The user prefers long answers."); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith( + "user:profile", + "The user prefers long answers.", + '{\n "tags": [\n "example"\n ]\n}', + false, + ); + }); + + it("sends an empty metadata string for a row that has none", async () => { + const user = userEvent.setup(); + renderModal({ mode: "edit", initialRow: { ...existingRow, metadata: null } }); + + expect(await screen.findByLabelText(/^Metadata/)).toHaveValue(""); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith("user:profile", "The user prefers concise answers.", "", false); + }); + + it("keeps the modal open when the save is rejected by the caller", async () => { + const user = userEvent.setup(); + onSave.mockResolvedValue(false); + renderModal(); + + await fill(user, /^Key/, "user_role"); + await fill(user, /^Value/, "Remembers the user is an admin"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("reports each required field as soon as it is emptied", async () => { + const user = userEvent.setup(); + renderModal(); + + await fill(user, /^Key/, "user_role"); + await fill(user, /^Value/, "Remembers the user is an admin"); + expect(screen.queryByText("Key is required")).not.toBeInTheDocument(); + + await user.clear(screen.getByLabelText(/^Key/)); + expect(await screen.findByText("Key is required")).toBeInTheDocument(); + + await user.clear(screen.getByLabelText(/^Value/)); + expect(await screen.findByText("Value is required")).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx index 594b35ddade..f36f6cb30c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx @@ -1,10 +1,37 @@ "use client"; +import { Modal } from "antd"; +import { CircleHelp } from "lucide-react"; import React, { useEffect, useState } from "react"; -import { Form, Input, Modal, Typography } from "antd"; -import type { MemoryRow } from "@/components/networking"; +import { z } from "zod/v4"; -const { Text } = Typography; +import type { MemoryRow } from "@/components/networking"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; + +const memorySchema = z.object({ + key: z.string().min(1, "Key is required"), + value: z.string().min(1, "Value is required"), + metadata: z.string(), +}); + +type MemoryFormValues = z.output; + +const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +const EMPTY_MEMORY: MemoryFormValues = { key: "", value: "", metadata: "" }; interface MemoryEditModalProps { open: boolean; @@ -15,39 +42,37 @@ interface MemoryEditModalProps { } export const MemoryEditModal: React.FC = ({ open, mode, initialRow, onClose, onSave }) => { - const [form] = Form.useForm(); + const form = useZodForm(memorySchema, { defaultValues: EMPTY_MEMORY, mode: "onChange" }); const [submitting, setSubmitting] = useState(false); useEffect(() => { if (!open) return; if (mode === "edit" && initialRow) { - form.setFieldsValue({ + form.reset({ key: initialRow.key, value: initialRow.value, metadata: initialRow.metadata != null ? JSON.stringify(initialRow.metadata, null, 2) : "", }); - } else { - form.resetFields(); + return; } + form.reset(EMPTY_MEMORY); }, [open, mode, initialRow, form]); - const handleOk = async () => { - const values = await form.validateFields(); + const handleOk = form.handleSubmit(async (values) => { setSubmitting(true); - const ok = await onSave(values.key.trim(), values.value ?? "", values.metadata ?? "", mode === "create"); + const ok = await onSave(values.key.trim(), values.value, values.metadata, mode === "create"); setSubmitting(false); - if (ok) { - form.resetFields(); - onClose(); - } - }; + if (!ok) return; + form.reset(EMPTY_MEMORY); + onClose(); + }); return ( { - form.resetFields(); + form.reset(EMPTY_MEMORY); onClose(); }} onOk={handleOk} @@ -56,39 +81,49 @@ export const MemoryEditModal: React.FC = ({ open, mode, in width={640} destroyOnClose > -
- - - - - - - - Metadata (optional JSON) - - } - name="metadata" - tooltip="Optional structured metadata — must be valid JSON if provided." - > - - -
+
event.preventDefault()} noValidate> + + + + {({ ref, ...field }) => ( + + )} + + + + {({ ref, ...field }) => ( +