loopable / web /src /home /TemplatePicker.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
618de96 verified
Raw
History Blame Contribute Delete
8.42 kB
// ---------------------------------------------------------------------------
// 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<string, unknown>;
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<Load>({ 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 (
<p className="home-tpl-note">
A template adds views to a database. Create one first — "Blank" above.
</p>
);
}
return (
<div className="home-tpl">
<label className="home-tpl-target">
<span className="home-tpl-label">Apply to</span>
{/* ⚠ A `<select>` MUST CARRY ITS VALUE. A select rendered without one shows its FIRST
option while the state says something else — the wave-22 scar ([[cg-condition-builder-items]]);
the control then reads as "customer_data" and applies to whatever `table` happens to
hold. Bound, not defaulted. */}
<select
className="home-tpl-select"
value={table}
onChange={(e) => setTable(e.target.value)}
>
{tables.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</label>
{load.phase === "loading" ? (
<div className="home-tpl-note">
<span className="lp-spin" role="status" aria-label="Loading" />
</div>
) : null}
{load.phase === "error" ? <p className="shell-newdb-err">{load.message}</p> : 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".
<p className="home-tpl-note">No template fits this database's columns.</p>
) : null}
{load.phase === "ready" && load.rows.length > 0 ? (
<ul className="home-tpl-list">
{load.rows.map((row) => (
<li key={row.key} className="home-tpl-row">
<span className="home-tpl-text">
<span className="home-tpl-name">{row.label}</span>
<span className="home-tpl-desc">{row.desc}</span>
</span>
<button
type="button"
className="login-submit home-tpl-apply"
disabled={!!busy}
onClick={() => apply(row.key)}
>
{busy === row.key ? "Adding…" : `Add ${row.views}`}
</button>
</li>
))}
</ul>
) : null}
</div>
);
}