Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Header } from "@/components/Header";
import { Toaster } from "@/components/Toaster";
import { QueryProvider } from "@/providers/QueryProvider";
import { BrowserRouter, Route, Routes } from "react-router";
import { AddVault } from "./routes/AddVault";
import { Home } from "./routes/Home";
import { NoteEditor } from "./routes/NoteEditor";
import { NoteNew } from "./routes/NoteNew";
import { NoteView } from "./routes/NoteView";
import { Notes } from "./routes/Notes";
import { OAuthCallback } from "./routes/OAuthCallback";
Expand All @@ -14,11 +16,13 @@ export function App() {
<QueryProvider>
<BrowserRouter>
<div className="min-h-dvh bg-bg text-fg">
<Toaster />
<Header />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/new" element={<NoteNew />} />
<Route path="/notes/:id" element={<NoteView />} />
<Route path="/notes/:id/edit" element={<NoteEditor />} />
<Route path="/add" element={<AddVault />} />
Expand Down
61 changes: 5 additions & 56 deletions src/app/routes/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { CodeMirrorEditor } from "@/components/CodeMirrorEditor";
import { DeleteNoteButton } from "@/components/DeleteNoteButton";
import { MarkdownView, buildWikilinkResolver } from "@/components/MarkdownView";
import { TagEditor, normalizeTag } from "@/components/TagEditor";
import { relativeTime } from "@/lib/time";
import { useNote, useUpdateNote, useVaultStore } from "@/lib/vault";
import { type UpdateNotePayload, VaultAuthError, VaultConflictError } from "@/lib/vault/client";
Expand Down Expand Up @@ -136,7 +138,7 @@ function EditorSurface({ note }: { note: Note }) {
const pathChanged = draft.path !== baseline.path;

const addTag = (raw: string) => {
const t = raw.trim().replace(/^#/, "");
const t = normalizeTag(raw);
if (!t) return;
if (draft.tags.includes(t)) return;
setDraft((d) => ({ ...d, tags: [...d.tags, t] }));
Expand Down Expand Up @@ -169,6 +171,8 @@ function EditorSurface({ note }: { note: Note }) {
)}
</div>
<div className="flex items-center gap-2">
<DeleteNoteButton note={note} />
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<button
type="button"
onClick={handleRevert}
Expand Down Expand Up @@ -253,61 +257,6 @@ function EditorSurface({ note }: { note: Note }) {
);
}

function TagEditor({
tags,
input,
onInputChange,
onAdd,
onRemove,
}: {
tags: string[];
input: string;
onInputChange(v: string): void;
onAdd(raw: string): void;
onRemove(name: string): void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5 text-sm">
<span className="shrink-0 text-xs uppercase tracking-wider text-fg-dim">Tags</span>
{tags.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 rounded-full border border-border bg-bg/60 px-2 py-0.5 text-xs text-fg-muted"
>
{t}
<button
type="button"
onClick={() => onRemove(t)}
aria-label={`Remove tag ${t}`}
className="text-fg-dim hover:text-red-400"
>
×
</button>
</span>
))}
<input
type="text"
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
onAdd(input);
} else if (e.key === "Backspace" && input === "" && tags.length > 0) {
onRemove(tags[tags.length - 1]!);
}
}}
onBlur={() => {
if (input.trim()) onAdd(input);
}}
placeholder="add tag…"
className="min-w-24 flex-1 rounded-md border border-transparent bg-transparent px-1 py-0.5 text-xs text-fg focus:border-border focus:outline-none"
aria-label="Add tag"
/>
</div>
);
}

function ConflictBanner({
conflict,
onReload,
Expand Down
196 changes: 196 additions & 0 deletions src/app/routes/NoteNew.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { NoteNew } from "@/app/routes/NoteNew";
import { useToastStore } from "@/lib/toast/store";
import { useVaultStore } from "@/lib/vault/store";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/components/CodeMirrorEditor", () => ({
CodeMirrorEditor: ({
value,
onChange,
}: { value: string; onChange(next: string): void; onSave?(): void; onCancel?(): void }) => (
<textarea data-testid="cm-editor" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));

interface FetchEntry {
status?: number;
body: unknown;
text?: string;
}
type FetchMap = Record<string, FetchEntry | FetchEntry[]>;

function installFetch(map: FetchMap) {
const cursors = new Map<string, number>();
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const method = (init?.method ?? "GET").toUpperCase();
for (const matcher of Object.keys(map)) {
const [wantMethod, wantFragment] = matcher.includes(" ")
? matcher.split(" ", 2)
: ["GET", matcher];
if (method !== wantMethod) continue;
if (!url.includes(wantFragment!)) continue;
const entry = map[matcher]!;
const list = Array.isArray(entry) ? entry : [entry];
const idx = Math.min(cursors.get(matcher) ?? 0, list.length - 1);
cursors.set(matcher, idx + 1);
const hit = list[idx]!;
return {
ok: (hit.status ?? 200) < 400,
status: hit.status ?? 200,
json: async () => hit.body,
text: async () => hit.text ?? "",
} as Response;
}
return { ok: false, status: 404, json: async () => null, text: async () => "" } as Response;
});
vi.stubGlobal("fetch", fetchImpl);
return fetchImpl;
}

function seedStore() {
useVaultStore.setState({
vaults: {
dev: {
id: "dev",
url: "http://localhost:1940",
name: "dev",
issuer: "http://localhost:1940",
clientId: "client-test",
scope: "full",
addedAt: "2026-04-18T00:00:00.000Z",
lastUsedAt: "2026-04-18T00:00:00.000Z",
},
},
activeVaultId: "dev",
});
localStorage.setItem(
"lens:token:dev",
JSON.stringify({ accessToken: "pvt_abc", scope: "full", vault: "default" }),
);
}

function Wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/new" element={<NoteNew />} />
<Route path="/notes/:id" element={<div>NoteViewPage</div>} />
<Route path="/notes" element={<div>NotesListPage</div>} />
</Routes>
</MemoryRouter>,
{ wrapper: Wrapper },
);
}

describe("NoteNew route", () => {
beforeEach(() => {
localStorage.clear();
useVaultStore.setState({ vaults: {}, activeVaultId: null });
useToastStore.setState({ toasts: [] });
seedStore();
vi.spyOn(window, "confirm").mockImplementation(() => true);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("Create is disabled until both path and content are present", async () => {
installFetch({});
renderAt("/new");

const create = screen.getByRole("button", { name: /^create$/i });
expect(create).toBeDisabled();

const pathInput = screen.getByLabelText(/note path/i);
fireEvent.change(pathInput, { target: { value: "Projects/README" } });
expect(create).toBeDisabled(); // still need content

const cm = screen.getByTestId("cm-editor");
fireEvent.change(cm, { target: { value: "# hello" } });
expect(create).not.toBeDisabled();
});

it("happy path: POSTs payload and navigates to /notes/<new-id>", async () => {
const fetchImpl = installFetch({
"POST /api/notes": {
status: 201,
body: {
id: "new-note-id",
path: "Projects/README",
createdAt: "2026-04-18T12:00:00Z",
content: "# hi",
tags: ["docs"],
},
},
});

renderAt("/new");

fireEvent.change(screen.getByLabelText(/note path/i), {
target: { value: "Projects/README" },
});
fireEvent.change(screen.getByLabelText(/note summary/i), {
target: { value: "A readme" },
});
const tagInput = screen.getByLabelText(/add tag/i);
fireEvent.change(tagInput, { target: { value: "docs" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
fireEvent.change(screen.getByTestId("cm-editor"), { target: { value: "# hi" } });

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));
});

await waitFor(() => {
expect(screen.getByText("NoteViewPage")).toBeInTheDocument();
});

const postCall = fetchImpl.mock.calls.find(
([, init]) => (init as RequestInit | undefined)?.method === "POST",
);
expect(postCall).toBeDefined();
const body = JSON.parse((postCall![1] as RequestInit).body as string);
expect(body).toEqual({
content: "# hi",
path: "Projects/README",
tags: ["docs"],
metadata: { summary: "A readme" },
});

expect(useToastStore.getState().toasts[0]?.message).toContain("Created");
});

it("duplicate path: error is visible and content/path are preserved", async () => {
installFetch({
"POST /api/notes": {
status: 500,
body: null,
text: '{"error":"Internal server error"}',
},
});

renderAt("/new");

fireEvent.change(screen.getByLabelText(/note path/i), { target: { value: "dup" } });
fireEvent.change(screen.getByTestId("cm-editor"), { target: { value: "keep me" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));
});

expect(await screen.findByRole("alert")).toHaveTextContent(/500|path is taken/i);
expect((screen.getByLabelText(/note path/i) as HTMLInputElement).value).toBe("dup");
expect((screen.getByTestId("cm-editor") as HTMLTextAreaElement).value).toBe("keep me");
});
});
Loading