// --------------------------------------------------------------------------- // home/TemplatePicker.tsx — WAVE 23 item 7 (R10, contract C12, wiring W23-W6). // // A template is a bundle of SAVED VIEWS applied to a database you already have — // it does not create a table. So the picker asks two questions in the order a // person actually answers them: WHICH DATABASE, then WHICH TEMPLATE. The second // list is a function of the first, because the server filters templates by the // target's real columns (`view_templates.missing_columns`), and offering one the // apply door would refuse is a control that lies. // // It renders inside the New-database dialog's "From a template" mode rather than // as a third modal: one dialog, several doors (DESIGN.md §4 — new elements join // the existing rhythm). // --------------------------------------------------------------------------- import { useEffect, useState } from "react"; import { API_V1, CREDENTIALS } from "../apiContract"; import type { DatabaseEntry } from "../shell/nav"; /** One row of `GET /templates?table=` — the server's vocabulary, read as strings (the wave-9 * law: no client union over a server list). */ interface TemplateRow { key: string; label: string; desc: string; source: string; views: number; alert: boolean; } type Load = | { phase: "idle" } | { phase: "loading" } | { phase: "ready"; rows: TemplateRow[] } | { phase: "error"; message: string }; function parseRows(body: unknown): TemplateRow[] { const raw = (body as { templates?: unknown } | null)?.templates; if (!Array.isArray(raw)) return []; const out: TemplateRow[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const t = item as Record; const key = typeof t.key === "string" ? t.key : ""; const label = typeof t.label === "string" ? t.label : ""; if (!key || !label) continue; out.push({ key, label, desc: typeof t.desc === "string" ? t.desc : "", source: typeof t.source === "string" ? t.source : "", views: typeof t.views === "number" ? t.views : 0, alert: t.alert === true, }); } return out; } export default function TemplatePicker({ entries, onToast, onDone, }: { /** * The databases this session may open — the shaped, server-filtered nav, minus the surfaces. * * ⭐ WAVE 25 (D-54) — the "minus Automation" in that sentence used to be a promise the CALLER * had to keep; it is now the type. `DatabaseEntry[]` can only come from `databaseEntries()`, * so a template can never be offered for application to a surface that has no table under it. */ entries: DatabaseEntry[]; onToast: (message: string) => void; /** Applied: close the dialog and open the database, where the new views now are. */ onDone: (tableKey: string) => void; }) { // Only real destinations: a group head is a folder label with no table behind it. const tables = entries.filter((e) => e.kind === "native"); const [table, setTable] = useState(tables[0]?.key ?? ""); const [load, setLoad] = useState({ phase: "idle" }); const [busy, setBusy] = useState(""); useEffect(() => { if (!table) { setLoad({ phase: "ready", rows: [] }); return; } let dead = false; setLoad({ phase: "loading" }); void (async () => { try { const res = await fetch( `${API_V1}/templates?table=${encodeURIComponent(table)}`, { credentials: CREDENTIALS } ); const body = (await res.json().catch(() => null)) as | { error?: { message?: string } } | null; if (dead) return; if (!res.ok) { setLoad({ phase: "error", message: body?.error?.message || `The server answered ${res.status}.`, }); return; } setLoad({ phase: "ready", rows: parseRows(body) }); } catch { if (!dead) setLoad({ phase: "error", message: "Cannot reach the server." }); } })(); return () => { dead = true; }; }, [table]); const apply = (key: string) => { setBusy(key); void (async () => { try { const res = await fetch( `${API_V1}/templates/${encodeURIComponent(key)}/apply`, { method: "POST", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ table }), } ); const body = (await res.json().catch(() => null)) as | { views?: { name?: string }[]; alerted?: boolean; error?: { message?: string } } | null; setBusy(""); if (!res.ok) { // ⚠ THE SERVER'S SENTENCE, VERBATIM. A refusal here names the COLUMNS the target is // missing, which is the only thing that tells the reader they picked the wrong // database — replacing it with "could not apply" would throw that away. onToast(body?.error?.message || `The server answered ${res.status}.`); return; } const n = (body?.views ?? []).length; onToast( `${n} view${n === 1 ? "" : "s"} added.` + (body?.alerted ? " An alert is now watching the first one." : "") ); onDone(table); } catch { setBusy(""); onToast("Cannot reach the server."); } })(); }; // ⛔ A WORKSPACE WITH NO DATABASES IS NOT "NO TEMPLATE FITS". Without this branch the select // renders empty, `table` is "", the fetch is skipped, and the reader is told // "No template fits this database's columns." about a database they do not have — a false // sentence, on the FIRST thing a brand-new workspace can click (Home's "Start with templates" // card sits on exactly the empty state the tenant hero used to own). The distinction is the // same one the Database flyout already makes between a search that matched nothing and a // workspace that holds nothing; this branch is the third place it has to be made. if (tables.length === 0) { return (

A template adds views to a database. Create one first — "Blank" above.

); } return (
{load.phase === "loading" ? (
) : null} {load.phase === "error" ?

{load.message}

: null} {load.phase === "ready" && load.rows.length === 0 ? ( // ONE line (R13). And it says the RIGHT thing: nothing fits THIS database, which is not // the same as "there are no templates".

No template fits this database's columns.

) : null} {load.phase === "ready" && load.rows.length > 0 ? (
    {load.rows.map((row) => (
  • {row.label} {row.desc}
  • ))}
) : null}
); }