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');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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