ui
tcmts.examples.notes.ui · #a4bz5pfj · tsx file
// tcm: namespace notes
// The page, as React components. None of them says where it runs:
//
// - NoteList, NotePreview and NoteEditorLoader read the notes table (they're async), so they render on the server;
// the browser fetches their rendered trees by hash, and fetches them again after it calls a server function.
// - App, SearchField, SidebarNote, NoteEditor and the buttons keep state or handle events, so they run in the
// browser. The ones rendered inside server components (SidebarNote, NoteEditor, EditButton) are sent there by
// hash, with the props the server gave them.
// - Markdown uses neither, so it renders wherever it's used: on the server in NotePreview, in the browser in
// NoteEditor's live preview.
import assert from "node:assert/strict";
import { Suspense, useState, ViewTransition } from "react";
import { navigate, Stylesheet, useLocation, useNavigating } from "#codebase/notes/lib/react";
import { expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { addSampleNotes, deleteNote, getNote, listNotes, Markdown, saveNote, type NoteSummary } from "#codebase/notes";
// the page's look: styles.css, a file of the codebase like the code (its text, as a string); <Stylesheet> links to
// it as a file the app's build serves
import { styles } from "#codebase/notes";
// for the README's example
import { excerpt, sampleNotes } from "#codebase/notes";
/** What the page shows, kept in the URL (`?note=<id>&edit=1&q=milk`), so links and the back button work. */
export type Route = { note?: string; edit: boolean; q: string };
/**
* Read a {@link Route} from a URL's query string.
*
* ```ts
* parseRoute("?note=abc&edit=1&q=milk")
* parseRoute("")
* ```
*/
export function parseRoute(search: string): Route {
const p = new URLSearchParams(search);
return { note: p.get("note") ?? undefined, edit: p.get("edit") === "1", q: p.get("q") ?? "" };
}
/**
* The query string for a {@link Route}; {@link parseRoute} reads it back.
*
* ```ts
* routeQuery({ note: "abc", edit: true, q: "" })
* ```
*/
export function routeQuery(route: Route): string {
const p = new URLSearchParams();
if (route.note) p.set("note", route.note);
if (route.edit) p.set("edit", "1");
if (route.q) p.set("q", route.q);
const s = p.toString();
return s ? `?${s}` : "";
}
/**
* The current {@link Route}, from the react library's location: the component re-renders when it changes. On the server,
* while tcm renders the first paint, it's the page's URL.
*/
export function useRoute(): Route {
return parseRoute(useLocation().query.toString());
}
/** Show another {@link Route}: `to` changes some of the current one. `replace` doesn't add a history entry. */
export function show(to: Partial<Route>, replace = false): void {
navigate(routeQuery({ ...parseRoute(location.search), ...to }) || location.pathname, { replace, scroll: false });
}
/**
* The sidebar's list: a server component. It reads the table through {@link listNotes} on the server; each entry
* is a {@link SidebarNote}, which the browser renders. Its props are only the search, so picking a note doesn't
* fetch it again.
*/
export async function NoteList({ search }: { search: string }) {
const found = await listNotes(search);
if (!found.length) return search ? <p className="none">No notes match “{search}”.</p> : <EmptySidebar />;
return (
<ul className="list">
{found.map((n) => (
<SidebarNote key={n.id} note={n} />
))}
</ul>
);
}
/**
* One entry in the sidebar: a browser component (it handles clicks, and reads the URL for whether it's the note
* shown), rendered inside {@link NoteList}.
*/
export function SidebarNote({ note }: { note: NoteSummary }) {
const selected = useRoute().note === note.id;
return (
<li>
<button className={selected ? "entry selected" : "entry"} onClick={() => show({ note: note.id, edit: false })}>
<strong>{note.title}</strong>
<small>{note.updated}</small>
<span>{note.excerpt || "(empty)"}</span>
</button>
</li>
);
}
/** No notes yet: offer the samples. */
export function EmptySidebar() {
const [adding, setAdding] = useState(false);
return (
<div className="none">
<p>No notes yet.</p>
<button
disabled={adding}
onClick={async () => {
setAdding(true);
await addSampleNotes();
}}
>
add sample notes
</button>
</div>
);
}
/**
* A note, read-only: a server component. Its body is rendered by {@link Markdown} right here on the server; the
* browser only gets the elements.
*/
export async function NotePreview({ id }: { id: string }) {
const note = await getNote(id);
if (!note) return <p className="none">This note was deleted.</p>;
return (
<article>
<header>
<h1>{note.title}</h1>
<EditButton />
</header>
<small>last changed {note.updatedAt.toISOString().slice(0, 16).replace("T", " ")} UTC</small>
<Markdown text={note.body} />
</article>
);
}
/** Switch the current note to the editor. */
export function EditButton() {
return <button onClick={() => show({ edit: true })}>edit</button>;
}
/** Loads a note for {@link NoteEditor} on the server, so the editor starts with the stored text. */
export async function NoteEditorLoader({ id }: { id: string }) {
const note = await getNote(id);
if (!note) return <p className="none">This note was deleted.</p>;
return <NoteEditor key={id} id={id} title={note.title} body={note.body} />;
}
/**
* Edit a note, or write a new one (no `id`), with a live preview: a browser component. The preview is the same
* {@link Markdown} that {@link NotePreview} renders on the server, rendered here as you type.
*/
export function NoteEditor({ id, title, body }: { id?: string; title: string; body: string }) {
const [draftTitle, setTitle] = useState(title);
const [draft, setBody] = useState(body);
const [busy, setBusy] = useState(false);
return (
<form
className="editor"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
const saved = await saveNote(id, draftTitle, draft);
show({ note: saved, edit: false });
}}
>
<div className="fields">
<input aria-label="title" value={draftTitle} placeholder="title" onChange={(e) => setTitle(e.target.value)} />
<textarea aria-label="body" value={draft} placeholder="write markdown" onChange={(e) => setBody(e.target.value)} />
<div className="actions">
<button type="submit" disabled={busy}>
save
</button>
<button type="button" onClick={() => show({ edit: false, note: id })}>
cancel
</button>
{id !== undefined && (
<button
type="button"
disabled={busy}
onClick={async () => {
setBusy(true);
await deleteNote(id);
show({ note: undefined, edit: false });
}}
>
delete
</button>
)}
</div>
</div>
<section className="preview">
<h1>{draftTitle || "Untitled"}</h1>
<Markdown text={draft} />
</section>
</form>
);
}
/** The search box: its text is part of the URL, and {@link NoteList} gets it as a prop. */
export function SearchField({ value }: { value: string }) {
const [text, setText] = useState(value);
return (
<input
className="search"
aria-label="search"
placeholder="search"
value={text}
onChange={(e) => {
setText(e.target.value);
show({ q: e.target.value }, true);
}}
/>
);
}
/**
* The app: a browser component (it follows the URL), laying out the server components it renders. Navigating is a
* transition: the page stays as it is until the next note's tree is here (dimmed, if that takes a moment), then
* `<ViewTransition>` cross-fades to it. So the `<Suspense>` boundaries' "loading…" shows only when there's nothing
* to keep showing; the first paint is rendered on the server, so it has none.
*/
export function App() {
const route = useRoute();
const navigating = useNavigating();
return (
<div className="notes">
<Stylesheet css={styles} />
<aside>
<header>
<h1>notes</h1>
<button onClick={() => show({ note: undefined, edit: true })}>new</button>
</header>
<SearchField value={route.q} />
<Suspense fallback={<p className="none">loading…</p>}>
<NoteList search={route.q} />
</Suspense>
</aside>
<main className={navigating ? "navigating" : undefined}>
<Suspense fallback={<p className="none">loading…</p>}>
{/* a note in, the last one out (the boundary stays: what it shows stays until the next is here); the
same note changing, or the sidebar's search, doesn't animate */}
<ViewTransition key={`${route.note ?? ""}:${route.edit}`} update="none">
{route.edit ? (
route.note ? <NoteEditorLoader id={route.note} /> : <NoteEditor title="" body="" />
) : route.note ? (
<NotePreview id={route.note} />
) : (
<p className="none">Pick a note, or write a new one.</p>
)}
</ViewTransition>
</Suspense>
</main>
</div>
);
}
/**
* The editor's preview follows what you type. It renders a browser component (it keeps state), so it runs in a
* browser: tcm sees that, and runs it in Chrome.
*/
export const testEditorPreview = () => {
render(<NoteEditor title="Shopping" body="" />);
fireEvent.change(screen.getByLabelText("body"), { target: { value: "- **milk**" } });
expect(screen.getByText("milk").tagName).toBe("STRONG");
expect(screen.getByRole("heading", { name: "Shopping" })).toBeTruthy();
};
/** A route survives the round trip through the URL. */
export const testRouteRoundTrip = () => {
const route: Route = { note: "abc", edit: true, q: "milk eggs" };
assert.deepEqual(parseRoute(routeQuery(route)), route);
assert.deepEqual(parseRoute(""), { note: undefined, edit: false, q: "" });
assert.equal(routeQuery({ edit: false, q: "" }), "");
};
/**
* # Notes
*
* A notes app in the style of React's Server Components demo, without a single `"use client"` or `"use server"`:
* tcm infers what renders where from what each component uses.
*
* | part | renders | why |
* |---|---|---|
* | {@link NoteList}, {@link NotePreview}, {@link NoteEditorLoader} | server | async: they read the {@link notes.notes} table |
* | {@link App}, {@link SearchField}, {@link NoteEditor}, {@link SidebarNote} | browser | state and event handlers |
* | {@link Markdown} | anywhere | plain elements: server inside a preview, browser inside the editor |
* | {@link saveNote}, {@link deleteNote}, {@link listNotes} | server | called from the browser by hash |
*
* Server components fetch their trees again after the page calls a server function, so saving a note updates the
* sidebar without any wiring. The model is plain functions with tests:
*
* ```ts
* excerpt(sampleNotes[1]!.body, 50)
* ```
*
* Run it with **▶ Start** in the Apps view, or `tcm dev notes.App`.
*/
export type README = never;
used by