| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useState } from "react"; |
| import { API_V1, CREDENTIALS } from "../apiContract"; |
| import type { DatabaseEntry } from "../shell/nav"; |
|
|
| |
| |
| 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; |
| }) { |
| |
| 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) { |
| |
| |
| |
| 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."); |
| } |
| })(); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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> |
| ); |
| } |
| |