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