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 }) => ( +