feat(canvas/memories): Add + Edit modal for MemoryInspectorPanel
The Memory tab was read-only — users could see and Delete entries but
the only path to write was leaving canvas. Adds a + Add button (toolbar,
next to Refresh) and an Edit button (per-entry, next to Delete) that
share one MemoryEditorDialog.
Add: POST /workspaces/:id/memories with {content, scope, namespace}
Edit: PATCH /workspaces/:id/memories/:id (sibling endpoint #2838)
with only fields that changed; no-op edits short-circuit
client-side so we don't waste a redactSecrets + re-embed pass
Edit mode locks scope (cross-scope moves go through delete + recreate
to keep the GLOBAL audit-log + redact pipeline single-purpose).
Tests: 6 cases on the dialog covering POST shape, PATCH-only-diff,
no-op short-circuit, empty-content guard, save-error keeps modal open,
and namespace+content combined PATCH. Existing 27 MemoryInspectorPanel
tests still pass with the new prop wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0134353a48
commit
cbe48c2225
261
canvas/src/components/MemoryEditorDialog.tsx
Normal file
261
canvas/src/components/MemoryEditorDialog.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import type { MemoryEntry } from "@/components/MemoryInspectorPanel";
|
||||
|
||||
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
|
||||
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
|
||||
|
||||
interface AddProps {
|
||||
open: boolean;
|
||||
mode: "add";
|
||||
workspaceId: string;
|
||||
defaultScope: Scope;
|
||||
defaultNamespace?: string;
|
||||
entry?: undefined;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
open: boolean;
|
||||
mode: "edit";
|
||||
workspaceId: string;
|
||||
entry: MemoryEntry;
|
||||
defaultScope?: undefined;
|
||||
defaultNamespace?: undefined;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type Props = AddProps | EditProps;
|
||||
|
||||
export function MemoryEditorDialog(props: Props) {
|
||||
const { open, mode, workspaceId, onClose, onSaved } = props;
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scope, setScope] = useState<Scope>("LOCAL");
|
||||
const [namespace, setNamespace] = useState("general");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Reset form whenever the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
if (mode === "edit" && props.entry) {
|
||||
setScope(props.entry.scope);
|
||||
setNamespace(props.entry.namespace || "general");
|
||||
setContent(props.entry.content);
|
||||
} else if (mode === "add") {
|
||||
setScope(props.defaultScope);
|
||||
setNamespace(props.defaultNamespace || "general");
|
||||
setContent("");
|
||||
}
|
||||
// mode/props are stable per-open; intentional shallow deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Move focus into the dialog when it opens (WCAG SC 2.4.3).
|
||||
useEffect(() => {
|
||||
if (!open || !mounted) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLElement>("textarea, input, select")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, mounted]);
|
||||
|
||||
// Escape closes; Cmd/Ctrl-Enter saves.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const handleSaveRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCloseRef.current();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
setError("Content cannot be empty");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "add") {
|
||||
await api.post(`/workspaces/${workspaceId}/memories`, {
|
||||
content: trimmed,
|
||||
scope,
|
||||
namespace: namespace.trim() || "general",
|
||||
});
|
||||
} else {
|
||||
// PATCH only sends fields that changed. Content always changeable;
|
||||
// namespace only sent if it differs from the original (saves a
|
||||
// no-op write through redactSecrets + re-embed).
|
||||
const original = props.entry;
|
||||
const body: Record<string, string> = {};
|
||||
if (trimmed !== original.content) body.content = trimmed;
|
||||
const ns = namespace.trim() || "general";
|
||||
if (ns !== original.namespace) body.namespace = ns;
|
||||
if (Object.keys(body).length === 0) {
|
||||
// No-op edit — close without an HTTP round-trip.
|
||||
onSaved();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await api.patch(
|
||||
`/workspaces/${workspaceId}/memories/${encodeURIComponent(original.id)}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
const titleId = "memory-editor-title";
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<h3 id={titleId} className="text-sm font-semibold text-ink">
|
||||
{isEdit ? "Edit memory" : "Add memory"}
|
||||
</h3>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-soft block" htmlFor="memory-editor-scope">
|
||||
Scope
|
||||
</label>
|
||||
{isEdit ? (
|
||||
<div
|
||||
id="memory-editor-scope"
|
||||
className="text-[12px] font-mono text-ink-mid bg-surface rounded px-2 py-1.5 border border-line/50"
|
||||
title="Scope is fixed on edit. To move a memory across scopes, delete and re-create it."
|
||||
>
|
||||
{scope}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1" id="memory-editor-scope" role="radiogroup" aria-label="Scope">
|
||||
{SCOPES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === s}
|
||||
onClick={() => setScope(s)}
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
scope === s
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-card text-ink-mid hover:text-ink",
|
||||
].join(" ")}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="memory-editor-namespace" className="text-[10px] text-ink-soft block">
|
||||
Namespace
|
||||
</label>
|
||||
<input
|
||||
id="memory-editor-namespace"
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder="general"
|
||||
className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] text-ink placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="memory-editor-content" className="text-[10px] text-ink-soft block">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="memory-editor-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="What should the agent remember?"
|
||||
className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] font-mono text-ink placeholder-zinc-600 focus:outline-none transition-colors resize-y min-h-[100px] max-h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-2 py-1.5 bg-red-950/30 border border-red-800/40 rounded text-[11px] text-bad"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3.5 py-1.5 text-[13px] rounded-lg transition-colors bg-accent hover:bg-accent-strong text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving…" : isEdit ? "Save changes" : "Add memory"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { MemoryEditorDialog } from "@/components/MemoryEditorDialog";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -92,6 +93,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
// ── Delete state ─────────────────────────────────────────────────────────────
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
// ── Editor state (Add + Edit share one modal) ───────────────────────────────
|
||||
type EditorState =
|
||||
| { mode: "add" }
|
||||
| { mode: "edit"; entry: MemoryEntry }
|
||||
| null;
|
||||
const [editorState, setEditorState] = useState<EditorState>(null);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
@ -241,14 +249,24 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
? "1 memory"
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorState({ mode: "add" })}
|
||||
className="px-2 py-1 text-[11px] bg-accent hover:bg-accent-strong text-white rounded transition-colors"
|
||||
aria-label="Add memory"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
@ -307,6 +325,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<MemoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => setEditorState({ mode: "edit", entry })}
|
||||
onDelete={() => setPendingDeleteId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
@ -324,6 +343,29 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
/>
|
||||
|
||||
{/* Add / Edit dialog */}
|
||||
{editorState?.mode === "add" && (
|
||||
<MemoryEditorDialog
|
||||
open={true}
|
||||
mode="add"
|
||||
workspaceId={workspaceId}
|
||||
defaultScope={activeScope}
|
||||
defaultNamespace={activeNamespace || "general"}
|
||||
onClose={() => setEditorState(null)}
|
||||
onSaved={loadEntries}
|
||||
/>
|
||||
)}
|
||||
{editorState?.mode === "edit" && (
|
||||
<MemoryEditorDialog
|
||||
open={true}
|
||||
mode="edit"
|
||||
workspaceId={workspaceId}
|
||||
entry={editorState.entry}
|
||||
onClose={() => setEditorState(null)}
|
||||
onSaved={loadEntries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -332,10 +374,11 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
|
||||
interface MemoryEntryRowProps {
|
||||
entry: MemoryEntry;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
function MemoryEntryRow({ entry, onEdit, onDelete }: MemoryEntryRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
|
||||
|
||||
@ -413,17 +456,30 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Delete memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
aria-label="Edit memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-surface-card hover:bg-surface-elevated border border-line/40 rounded text-ink-mid hover:text-ink transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Delete memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
202
canvas/src/components/__tests__/MemoryEditorDialog.test.tsx
Normal file
202
canvas/src/components/__tests__/MemoryEditorDialog.test.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MemoryEditorDialog tests — covers Add (POST /memories) and Edit
|
||||
* (PATCH /memories/:id) flows. Pins:
|
||||
* - Add posts {content, scope, namespace} with the trimmed defaults
|
||||
* - Edit only sends fields that changed (no-op edit short-circuits, no PATCH fires)
|
||||
* - Empty content blocks save
|
||||
* - Save error surfaces in the dialog and keeps the modal open
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { MemoryEditorDialog } from "../MemoryEditorDialog";
|
||||
import type { MemoryEntry } from "../MemoryInspectorPanel";
|
||||
|
||||
const mockPost = vi.mocked(api.post);
|
||||
const mockPatch = vi.mocked(api.patch);
|
||||
|
||||
const SAMPLE: MemoryEntry = {
|
||||
id: "mem-x",
|
||||
workspace_id: "ws-1",
|
||||
content: "original content",
|
||||
scope: "TEAM",
|
||||
namespace: "procedures",
|
||||
created_at: "2026-04-17T12:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPost.mockResolvedValue({} as never);
|
||||
mockPatch.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Add mode", () => {
|
||||
it("POSTs scope+namespace+trimmed-content and calls onSaved+onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="add"
|
||||
workspaceId="ws-1"
|
||||
defaultScope="GLOBAL"
|
||||
defaultNamespace="facts"
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: " new fact " } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add memory$/i }));
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1));
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-1/memories", {
|
||||
content: "new fact",
|
||||
scope: "GLOBAL",
|
||||
namespace: "facts",
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks save when content is empty (whitespace-only)", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="add"
|
||||
workspaceId="ws-1"
|
||||
defaultScope="LOCAL"
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add memory$/i }));
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/empty/i);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit mode", () => {
|
||||
it("PATCHes only changed fields", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "rewritten content" } });
|
||||
// namespace untouched
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(1));
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories/mem-x",
|
||||
{ content: "rewritten content" },
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("no-op edit short-circuits (no PATCH fires) and still closes", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends namespace too when both content and namespace changed", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Content/i), {
|
||||
target: { value: "newer content" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Namespace/i), {
|
||||
target: { value: "blockers" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(1));
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories/mem-x",
|
||||
{ content: "newer content", namespace: "blockers" },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces save error and keeps the modal open", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
mockPatch.mockRejectedValueOnce(new Error("boom"));
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Content/i), {
|
||||
target: { value: "rewritten content" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/boom/),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user