feat(web+backend): add create note, archive/restore, link note flows [B1, B2, B8]

- backend: add POST /notes/:id/restore endpoint (mirrors archive pattern)
- web: CreateNoteModal component (workspace picker, title, body, tags)
- web: LinkNoteModal component (search, select, relationship type picker)
- web: Dashboard 'New Note' button + CreateNoteModal integration
- web: Note detail Archive/Restore buttons + LinkNote button
- web: 4 CreateNoteModal tests + 4 LinkNoteModal tests
- backend: 1 restore integration test
- Total: 76 backend tests, 14 web tests
This commit is contained in:
saravanakumardb1 2026-03-19 08:44:39 -07:00
parent bf2785bcf9
commit a3267e4b1b
9 changed files with 534 additions and 10 deletions

View File

@ -94,6 +94,18 @@ describe('notes routes — integration', () => {
expect(res.json().status).toBe('archived');
});
it('POST /notes/:id/restore sets status to active', async () => {
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
await app.inject({ method: 'POST', url: '/api/notes/note-1/archive', payload: { workspaceId: 'ws-1' } });
const res = await app.inject({
method: 'POST',
url: '/api/notes/note-1/restore',
payload: { workspaceId: 'ws-1' },
});
expect(res.statusCode).toBe(200);
expect(res.json().status).toBe('active');
});
it('POST /notes rejects invalid body', async () => {
const res = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'x' } });
expect(res.statusCode).toBe(400);

View File

@ -39,7 +39,7 @@ describe('noteRoutes', () => {
await noteRoutes(app as never);
expect(app.get).toHaveBeenCalledTimes(3);
expect(app.post).toHaveBeenCalledTimes(2);
expect(app.post).toHaveBeenCalledTimes(3);
expect(app.patch).toHaveBeenCalledTimes(1);
});
});

View File

@ -120,6 +120,33 @@ export async function noteRoutes(app: RouteApp) {
return updated;
});
app.post('/notes/:id/restore', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const updated = await repo.updateNote(id, workspaceId, {
status: 'active',
updatedAt: new Date().toISOString(),
updatedBy: auth.sub,
});
if (!updated) {
throw new NotFoundError('Note not found');
}
return updated;
});
app.post('/notes/:id/archive', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };

View File

@ -3,6 +3,7 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CreateNoteModal } from "@/components/CreateNoteModal";
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
import { listApprovalQueue } from "@/lib/review-client";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
@ -12,6 +13,7 @@ export default function DashboardPage() {
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [pendingReviewCount, setPendingReviewCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const [showCreateNote, setShowCreateNote] = useState(false);
useEffect(() => {
void (async () => {
@ -123,7 +125,11 @@ export default function DashboardPage() {
<AppShell
title="Dashboard"
description="Operational entry point for recent notes, active workspaces, and agent-relevant follow-ups."
actions={<div className="badge">Operational shell</div>}
actions={
<button className="btn btn-primary" onClick={() => setShowCreateNote(true)}>
New Note
</button>
}
>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--ml-space-4)" }}>
{summaryCards.map((card) => (
@ -210,6 +216,16 @@ export default function DashboardPage() {
))}
</div>
</section>
{showCreateNote && (
<CreateNoteModal
workspaces={workspaces}
onCreated={() => {
setShowCreateNote(false);
window.location.reload();
}}
onClose={() => setShowCreateNote(false)}
/>
)}
</AppShell>
);
}

View File

@ -10,7 +10,8 @@ import { LinkedNotesPanel } from "@/components/LinkedNotesPanel";
import { TaskReviewPanel } from "@/components/TaskReviewPanel";
import { ArtifactPanel } from "@/components/ArtifactPanel";
import { AgentTimeline } from "@/components/AgentTimeline";
import { createNoteArtifact, createNoteTask, getNoteDetail, updateNoteDetail } from "@/lib/notes-client";
import { LinkNoteModal } from "@/components/LinkNoteModal";
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, updateNoteDetail } from "@/lib/notes-client";
import type { NoteDetail } from "@/lib/types";
export default function NoteDetailPage() {
@ -21,6 +22,7 @@ export default function NoteDetailPage() {
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [isCreatingArtifact, setIsCreatingArtifact] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showLinkNote, setShowLinkNote] = useState(false);
useEffect(() => {
void (async () => {
@ -109,6 +111,26 @@ export default function NoteDetailPage() {
}
}
async function handleArchive() {
if (!note) return;
try {
await archiveNote(note.id, note.workspaceId);
setNote(await getNoteDetail(note.id, note.workspaceId));
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to archive note");
}
}
async function handleRestore() {
if (!note) return;
try {
await restoreNote(note.id, note.workspaceId);
setNote(await getNoteDetail(note.id, note.workspaceId));
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to restore note");
}
}
if (!note) {
return (
<AppShell
@ -128,13 +150,23 @@ export default function NoteDetailPage() {
title={note.title}
description="Editable note surface with metadata, linked context, tasks, artifacts, and review history."
actions={
isSaving ? (
<div className="badge">Saving</div>
) : (
<Link href="/reviews" className="badge">
{`Review: ${note.metadata.reviewState}`}
</Link>
)
<div style={{ display: "flex", gap: "var(--ml-space-2)", alignItems: "center" }}>
{isSaving ? (
<div className="badge">Saving</div>
) : (
<Link href="/reviews" className="badge">
{`Review: ${note.metadata.reviewState}`}
</Link>
)}
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
Link Note
</button>
{note.status === "archived" ? (
<button className="btn btn-primary" onClick={handleRestore}>Restore</button>
) : (
<button className="btn btn-secondary" onClick={handleArchive}>Archive</button>
)}
</div>
}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.7fr) minmax(320px, 1fr)", gap: "var(--ml-space-4)" }}>
@ -149,6 +181,18 @@ export default function NoteDetailPage() {
<AgentTimeline items={note.timeline} />
</aside>
</div>
{showLinkNote && (
<LinkNoteModal
noteId={note.id}
workspaceId={note.workspaceId}
existingLinkedIds={note.linkedNotes.map((ln) => ln.id)}
onLinked={() => {
setShowLinkNote(false);
void getNoteDetail(note.id, note.workspaceId).then(setNote);
}}
onClose={() => setShowLinkNote(false)}
/>
)}
</AppShell>
);
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const createNoteMock = vi.fn();
vi.mock("@/lib/notes-client", () => ({
createNote: (...args: unknown[]) => createNoteMock(...args),
}));
import { CreateNoteModal } from "./CreateNoteModal";
const workspaces = [
{ id: "ws-1", name: "Product", description: "", owner: "u1", noteCount: 5, visibility: "private" as const, updatedAt: "", tags: [] },
{ id: "ws-2", name: "Design", description: "", owner: "u1", noteCount: 2, visibility: "private" as const, updatedAt: "", tags: [] },
];
describe("CreateNoteModal", () => {
beforeEach(() => {
createNoteMock.mockReset();
});
it("renders form fields", () => {
render(<CreateNoteModal workspaces={workspaces} onCreated={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByText("Create Note")).toBeDefined();
expect(screen.getByPlaceholderText("Note title")).toBeDefined();
expect(screen.getByPlaceholderText("Note content...")).toBeDefined();
});
it("disables submit when title or body is empty", () => {
render(<CreateNoteModal workspaces={workspaces} onCreated={vi.fn()} onClose={vi.fn()} />);
const btn = screen.getByText("Create");
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("calls createNote and onCreated on submit", async () => {
createNoteMock.mockResolvedValueOnce({});
const onCreated = vi.fn();
render(<CreateNoteModal workspaces={workspaces} onCreated={onCreated} onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText("Note title"), { target: { value: "My Note" } });
fireEvent.change(screen.getByPlaceholderText("Note content..."), { target: { value: "Some body" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(createNoteMock).toHaveBeenCalledTimes(1));
expect(createNoteMock.mock.calls[0][0].title).toBe("My Note");
expect(createNoteMock.mock.calls[0][0].body).toBe("Some body");
expect(createNoteMock.mock.calls[0][0].workspaceId).toBe("ws-1");
await waitFor(() => expect(onCreated).toHaveBeenCalled());
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
render(<CreateNoteModal workspaces={workspaces} onCreated={vi.fn()} onClose={onClose} />);
fireEvent.click(screen.getByText("Cancel"));
expect(onClose).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { createNote } from "@/lib/notes-client";
import type { WorkspaceSummary } from "@/lib/types";
interface CreateNoteModalProps {
workspaces: WorkspaceSummary[];
defaultWorkspaceId?: string;
onCreated: () => void;
onClose: () => void;
}
export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onClose }: CreateNoteModalProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [workspaceId, setWorkspaceId] = useState(defaultWorkspaceId ?? workspaces[0]?.id ?? "");
const [tags, setTags] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = title.trim().length > 0 && body.trim().length > 0 && workspaceId.length > 0;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit || saving) return;
setSaving(true);
setError(null);
try {
await createNote({
id: crypto.randomUUID(),
workspaceId,
title: title.trim(),
body: body.trim(),
tags: tags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
sourceType: "manual",
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create note");
} finally {
setSaving(false);
}
}
return (
<div
className="modal-overlay"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<form
onSubmit={handleSubmit}
className="surface-card"
style={{
padding: "var(--nl-space-6, 1.5rem)",
width: "100%",
maxWidth: 520,
display: "grid",
gap: "var(--nl-space-4, 1rem)",
}}
>
<div style={{ fontSize: "var(--nl-fs-xl, 1.25rem)", fontWeight: 700 }}>Create Note</div>
{error && <div style={{ color: "var(--nl-danger, #e53e3e)", fontSize: "0.875rem" }}>{error}</div>}
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Workspace</span>
<select value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} className="input">
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.name}
</option>
))}
</select>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Title</span>
<input
className="input"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
autoFocus
/>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Body</span>
<textarea
className="input"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Note content..."
rows={6}
style={{ resize: "vertical" }}
/>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Tags (comma-separated)</span>
<input
className="input"
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="launch, meeting, review"
/>
</label>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3, 0.75rem)" }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!canSubmit || saving}>
{saving ? "Creating..." : "Create"}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const searchNoteSummariesMock = vi.fn();
const createNoteRelationshipMock = vi.fn();
vi.mock("@/lib/notes-client", () => ({
searchNoteSummaries: (...args: unknown[]) => searchNoteSummariesMock(...args),
createNoteRelationship: (...args: unknown[]) => createNoteRelationshipMock(...args),
}));
import { LinkNoteModal } from "./LinkNoteModal";
describe("LinkNoteModal", () => {
beforeEach(() => {
searchNoteSummariesMock.mockReset();
createNoteRelationshipMock.mockReset();
});
it("renders search input and buttons", () => {
render(
<LinkNoteModal noteId="n1" workspaceId="ws-1" existingLinkedIds={[]} onLinked={vi.fn()} onClose={vi.fn()} />
);
expect(screen.getByText("Link Note")).toBeDefined();
expect(screen.getByPlaceholderText("Search notes...")).toBeDefined();
expect(screen.getByText("Search")).toBeDefined();
});
it("searches and shows results", async () => {
searchNoteSummariesMock.mockResolvedValueOnce([
{ id: "n2", title: "Target note", excerpt: "Some text", tags: [], status: "active", workspaceId: "ws-1", updatedAt: "", updatedBy: "" },
]);
render(
<LinkNoteModal noteId="n1" workspaceId="ws-1" existingLinkedIds={[]} onLinked={vi.fn()} onClose={vi.fn()} />
);
fireEvent.change(screen.getByPlaceholderText("Search notes..."), { target: { value: "Target" } });
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(screen.getByText("Target note")).toBeDefined());
});
it("excludes current note and already-linked notes from results", async () => {
searchNoteSummariesMock.mockResolvedValueOnce([
{ id: "n1", title: "Self", excerpt: "", tags: [], status: "active", workspaceId: "ws-1", updatedAt: "", updatedBy: "" },
{ id: "n3", title: "Already linked", excerpt: "", tags: [], status: "active", workspaceId: "ws-1", updatedAt: "", updatedBy: "" },
{ id: "n4", title: "Available note", excerpt: "", tags: [], status: "active", workspaceId: "ws-1", updatedAt: "", updatedBy: "" },
]);
render(
<LinkNoteModal noteId="n1" workspaceId="ws-1" existingLinkedIds={["n3"]} onLinked={vi.fn()} onClose={vi.fn()} />
);
fireEvent.change(screen.getByPlaceholderText("Search notes..."), { target: { value: "note" } });
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(screen.getByText("Available note")).toBeDefined());
expect(screen.queryByText("Self")).toBeNull();
expect(screen.queryByText("Already linked")).toBeNull();
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<LinkNoteModal noteId="n1" workspaceId="ws-1" existingLinkedIds={[]} onLinked={vi.fn()} onClose={onClose} />
);
fireEvent.click(screen.getByText("Cancel"));
expect(onClose).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { searchNoteSummaries, createNoteRelationship } from "@/lib/notes-client";
import type { NoteSummary } from "@/lib/types";
interface LinkNoteModalProps {
noteId: string;
workspaceId: string;
existingLinkedIds: string[];
onLinked: () => void;
onClose: () => void;
}
const RELATIONSHIP_TYPES = ["related", "parent", "child", "blocks", "blocked_by", "duplicate"] as const;
export function LinkNoteModal({ noteId, workspaceId, existingLinkedIds, onLinked, onClose }: LinkNoteModalProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<NoteSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [relationshipType, setRelationshipType] = useState<string>("related");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searched, setSearched] = useState(false);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
try {
const notes = await searchNoteSummaries(query.trim());
const filtered = notes.filter((n) => n.id !== noteId && !existingLinkedIds.includes(n.id));
setResults(filtered);
setSearched(true);
setSelectedId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Search failed");
}
}
async function handleLink() {
if (!selectedId || saving) return;
setSaving(true);
setError(null);
try {
await createNoteRelationship({
id: crypto.randomUUID(),
workspaceId,
fromNoteId: noteId,
toNoteId: selectedId,
relationshipType,
});
onLinked();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to link note");
} finally {
setSaving(false);
}
}
return (
<div
className="modal-overlay"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="surface-card"
style={{
padding: "var(--nl-space-6, 1.5rem)",
width: "100%",
maxWidth: 520,
display: "grid",
gap: "var(--nl-space-4, 1rem)",
}}
>
<div style={{ fontSize: "var(--nl-fs-xl, 1.25rem)", fontWeight: 700 }}>Link Note</div>
{error && <div style={{ color: "var(--nl-danger, #e53e3e)", fontSize: "0.875rem" }}>{error}</div>}
<form onSubmit={handleSearch} style={{ display: "flex", gap: "var(--nl-space-2, 0.5rem)" }}>
<input
className="input"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search notes..."
style={{ flex: 1 }}
autoFocus
/>
<button type="submit" className="btn btn-secondary">
Search
</button>
</form>
{searched && results.length === 0 && (
<div style={{ color: "var(--nl-text-secondary, #888)", fontSize: "0.875rem" }}>No matching notes found.</div>
)}
{results.length > 0 && (
<div style={{ maxHeight: 200, overflowY: "auto", display: "grid", gap: "var(--nl-space-2, 0.5rem)" }}>
{results.map((note) => (
<button
key={note.id}
type="button"
className={selectedId === note.id ? "surface-card" : "surface-muted"}
style={{
padding: "var(--nl-space-3, 0.75rem)",
textAlign: "left",
cursor: "pointer",
border: selectedId === note.id ? "2px solid var(--nl-accent, #5a8cff)" : "2px solid transparent",
}}
onClick={() => setSelectedId(note.id)}
>
<div style={{ fontWeight: 600 }}>{note.title}</div>
<div style={{ color: "var(--nl-text-secondary, #888)", fontSize: "0.875rem" }}>{note.excerpt}</div>
</button>
))}
</div>
)}
{selectedId && (
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Relationship type</span>
<select value={relationshipType} onChange={(e) => setRelationshipType(e.target.value)} className="input">
{RELATIONSHIP_TYPES.map((rt) => (
<option key={rt} value={rt}>
{rt.replace("_", " ")}
</option>
))}
</select>
</label>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3, 0.75rem)" }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="button" className="btn btn-primary" disabled={!selectedId || saving} onClick={handleLink}>
{saving ? "Linking..." : "Link"}
</button>
</div>
</div>
</div>
);
}