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:
parent
bf2785bcf9
commit
a3267e4b1b
@ -94,6 +94,18 @@ describe('notes routes — integration', () => {
|
|||||||
expect(res.json().status).toBe('archived');
|
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 () => {
|
it('POST /notes rejects invalid body', async () => {
|
||||||
const res = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'x' } });
|
const res = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'x' } });
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
|
|||||||
@ -39,7 +39,7 @@ describe('noteRoutes', () => {
|
|||||||
await noteRoutes(app as never);
|
await noteRoutes(app as never);
|
||||||
|
|
||||||
expect(app.get).toHaveBeenCalledTimes(3);
|
expect(app.get).toHaveBeenCalledTimes(3);
|
||||||
expect(app.post).toHaveBeenCalledTimes(2);
|
expect(app.post).toHaveBeenCalledTimes(3);
|
||||||
expect(app.patch).toHaveBeenCalledTimes(1);
|
expect(app.patch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -120,6 +120,33 @@ export async function noteRoutes(app: RouteApp) {
|
|||||||
return updated;
|
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 => {
|
app.post('/notes/:id/archive', async req => {
|
||||||
const auth = await extractAuth(req);
|
const auth = await extractAuth(req);
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CreateNoteModal } from "@/components/CreateNoteModal";
|
||||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
||||||
import { listApprovalQueue } from "@/lib/review-client";
|
import { listApprovalQueue } from "@/lib/review-client";
|
||||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
@ -12,6 +13,7 @@ export default function DashboardPage() {
|
|||||||
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
||||||
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreateNote, setShowCreateNote] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@ -123,7 +125,11 @@ export default function DashboardPage() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description="Operational entry point for recent notes, active workspaces, and agent-relevant follow-ups."
|
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)" }}>
|
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--ml-space-4)" }}>
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
@ -210,6 +216,16 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{showCreateNote && (
|
||||||
|
<CreateNoteModal
|
||||||
|
workspaces={workspaces}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowCreateNote(false);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
onClose={() => setShowCreateNote(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import { LinkedNotesPanel } from "@/components/LinkedNotesPanel";
|
|||||||
import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
||||||
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
||||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
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";
|
import type { NoteDetail } from "@/lib/types";
|
||||||
|
|
||||||
export default function NoteDetailPage() {
|
export default function NoteDetailPage() {
|
||||||
@ -21,6 +22,7 @@ export default function NoteDetailPage() {
|
|||||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||||
const [isCreatingArtifact, setIsCreatingArtifact] = useState(false);
|
const [isCreatingArtifact, setIsCreatingArtifact] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showLinkNote, setShowLinkNote] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
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) {
|
if (!note) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@ -128,13 +150,23 @@ export default function NoteDetailPage() {
|
|||||||
title={note.title}
|
title={note.title}
|
||||||
description="Editable note surface with metadata, linked context, tasks, artifacts, and review history."
|
description="Editable note surface with metadata, linked context, tasks, artifacts, and review history."
|
||||||
actions={
|
actions={
|
||||||
isSaving ? (
|
<div style={{ display: "flex", gap: "var(--ml-space-2)", alignItems: "center" }}>
|
||||||
<div className="badge">Saving</div>
|
{isSaving ? (
|
||||||
) : (
|
<div className="badge">Saving</div>
|
||||||
<Link href="/reviews" className="badge">
|
) : (
|
||||||
{`Review: ${note.metadata.reviewState}`}
|
<Link href="/reviews" className="badge">
|
||||||
</Link>
|
{`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)" }}>
|
<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} />
|
<AgentTimeline items={note.timeline} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
web/src/components/CreateNoteModal.test.tsx
Normal file
57
web/src/components/CreateNoteModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
web/src/components/CreateNoteModal.tsx
Normal file
139
web/src/components/CreateNoteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web/src/components/LinkNoteModal.test.tsx
Normal file
71
web/src/components/LinkNoteModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
158
web/src/components/LinkNoteModal.tsx
Normal file
158
web/src/components/LinkNoteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user