/* ═══════════════════════════════════════════════════════════════ ADMIN — ENTITY EDIT Create and edit for every entity in the manifest. The form holds the whole nested object — parent row, side tables, child collections — and PATCH sends the lot. The server replaces children wholesale, so what you see here is exactly what will exist afterwards. Nothing is written until Save, which is what makes removing a repeater row safe: leaving without saving undoes it. That only holds if leaving is hard to do by accident, hence the dirty tracking and the two guards below. A field the form never sets is left out of the payload entirely, and the server lets the column's own DEFAULT apply. So a blank new-record form is deliberate, not lazy — writing "" into every field is what used to turn a default into a constraint failure. updated_at rides along untouched. If someone else saved while this page was open the server answers 409 rather than letting one of you quietly overwrite the other. Entities with no updated_at column simply never get one, and the 409 path stays dormant for them. Two capabilities, not one role. An editor may create and update but not delete, so the action bar asks canWrite/canDelete rather than comparing user.role to a string. The comparison this replaced — role === "admin" — locked superadmins out of saving the moment a rank above admin existed, which is what an equality test against a ladder always eventually does. None of this is protection. The server refuses the request; this only decides whether to draw a button that would be refused. slugFrom may name one field or several. Most ids are unique because the name is: two organizations aren't both called Northwest. Team ids are the exception — teams.id is a global primary key, so every org's 'Board' would collide — which is why the array form exists and teams uses it. ═══════════════════════════════════════════════════════════════ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { get, post, patch, del, ApiError, type FieldErrors } from "../../lib/api.js"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { useAdminDetail } from "../../lib/adminTitle.tsx"; import { ADMIN_ENTITIES, slugify, type AdminOptions, type AdminRow, type FieldCondition, } from "../../lib/adminSchema.js"; import { atLeast } from "../../lib/roles.ts"; import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx"; /* A foreign key refusing to budge is the most common way a save or delete fails here, and SQLite's own wording explains nothing to whoever is filling in the form. */ function friendly(message: string, singular: string): string { if (/FOREIGN KEY constraint failed/i.test(message ?? "")) { return `Something still points at this ${singular}. Reassign or remove those first.`; } return message; } type RowResponse = { row: AdminRow }; type Notice = { tone: "ok" | "error"; text: string; recover?: "reload" }; export default function EntityEdit() { const { entity: entityKey, id } = useParams(); const manifest = entityKey ? ADMIN_ENTITIES[entityKey] : undefined; const navigate = useNavigate(); const { user } = useAuth(); const isNew = id === "new"; const canWrite = atLeast(user, "editor"); const canDelete = atLeast(user, "admin"); // Some entities have no slug: the table assigns an integer id, so // there is nothing to type on create and nothing to compose from // other fields. Timeline entries are the first — an entry that // references an event has no name of its own. const autoId = manifest?.idKind === "auto"; // Hoisted above the loading guards: the title hook below is a // hook, so it can't sit after an early return, and it needs the // same paths the heading uses. const slugPaths: string[] = Array.isArray(manifest?.slugFrom) ? manifest.slugFrom : [manifest?.slugFrom].filter((path): path is string => Boolean(path)); // Everything before the last path is a qualifier: a fact about // another field rather than something to type. It renders as // fixed text inside the slug box, and the editable part is only // what follows it. const qualifierPaths = slugPaths.slice(0, -1); // Empty until every qualifier is chosen, because half a prefix // would be saved into an id that then never matches. const prefixOf = (source: AdminRow | null) => { if (qualifierPaths.length === 0) return ""; const parts = qualifierPaths.map((path) => getPath(source, path)); if (parts.some((part) => !part)) return ""; return `${slugify(parts.join(" "))}-`; }; // What to show greyed out before then: org_id becomes "org-". const prefixHint = qualifierPaths.length ? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-` : ""; const tailOf = (source: AdminRow | null) => { const prefix = prefixOf(source); const value = String(source?.id ?? ""); return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value; }; // The heading wants the specific half, not the qualifier: a team // page reads "Board", not "northwest Board". An entity with no slug // names the field to read instead. const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom; // Blank when neither is set yet, which reads as "nothing to name". const headingOf = (row: AdminRow) => String(getPath(row, headingPath) || row.id || ""); const [form, setForm] = useState(null); const [options, setOptions] = useState({}); const [errors, setErrors] = useState>({}); const [message, setMessage] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [slugTouched, setSlugTouched] = useState(false); // The last state the server confirmed. Everything else compares // against this to decide whether there's anything to lose. const baseline = useRef(null); const load = useCallback(async () => { if (!manifest) return; setLoading(true); setErrors({}); try { const opts = await get<{ options: AdminOptions }>("/admin/options", { ttl: 60_000 }); setOptions(opts.options); if (isNew) { // Blank children arrays matter: an absent key means "don't // touch", which is wrong for a row that doesn't exist yet. // The parent's own fields stay absent on purpose so the // server's column defaults apply to whatever isn't filled in. // No id key for an auto entity: the table assigns it, and // sending "" would be an explicit value rather than an absence. const blank: AdminRow = autoId ? {} : { id: "" }; for (const child of manifest.children ?? []) blank[child.key] = []; setForm(blank); baseline.current = JSON.stringify(blank); } else { const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 }); setForm(data.row); baseline.current = JSON.stringify(data.row); } setMessage(null); } catch (err) { if (isUnauthorized(err)) return navigate("/admin/login", { replace: true }); setMessage({ tone: "error", text: err instanceof ApiError ? err.message : "Couldn't load that.", }); } finally { setLoading(false); } }, [manifest, id, isNew, autoId, navigate]); useEffect(() => { load(); }, [load]); const dirty = useMemo( () => Boolean(form) && JSON.stringify(form) !== baseline.current, [form], ); // The tab says what's on screen; the layout adds the section and // the site name. Null while loading, so it reads "Teams | NGU // Admin CMS" for the half-second before the record arrives // rather than flashing a slug. useAdminDetail( !manifest ? null : isNew ? `New ${manifest.singular}` : form ? headingOf(form) : null, ); // Closing the tab or hitting the browser back button skips React // Router entirely, so the only hook available is this one. useEffect(() => { if (!dirty) return undefined; const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); if (!manifest) return

No such thing to edit.

; if (loading || !form) return

Loading…

; /* ── Heading ───────────────────────────────────────────────── */ const heading = headingOf(form); const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null; const children = manifest.children ?? []; /* ── Actions ───────────────────────────────────────────────── */ const leave = (to: string) => { if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return; navigate(to); }; const change = (path: string, value: unknown) => { setForm((prev) => { let next = setPath(prev, path, value); // Recompose the id whenever one of its sources moves. The // prefix always follows the organization — picking a // different one has to change the slug, or it would claim a // team belongs somewhere it doesn't. The tail only follows // the name until someone types over it. if (isNew && slugPaths.includes(path)) { const tail = slugTouched ? tailOf(prev) : slugify(getPath(next, headingPath) ?? ""); next = { ...next, id: `${prefixOf(next)}${tail}` }; } return next; }); // Clear this field's error as soon as it's touched; leaving a // stale red outline on a field the user just fixed reads as a // save that didn't take. setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev)); }; const save = async () => { setSaving(true); setErrors({}); setMessage(null); try { const data = isNew ? await post(`/admin/${manifest.key}`, form) : await patch(`/admin/${manifest.key}/${id}`, form); setForm(data.row); baseline.current = JSON.stringify(data.row); setMessage({ tone: "ok", text: "Saved." }); if (isNew) navigate(`/admin/${manifest.key}/${String(data.row.id)}`, { replace: true }); } catch (err) { if (isUnauthorized(err)) return navigate("/admin/login", { replace: true }); if (err instanceof ApiError) { // 409 means the updated_at we're holding is stale. Every // further save will fail the same way until the page is // reloaded, so offer that rather than just saying no. if (err.status === 409) { setMessage({ tone: "error", text: err.message, recover: "reload" }); } else { setErrors(err.fields ?? {}); setMessage({ tone: "error", text: err.fields ? "Some fields need attention." : friendly(err.message, manifest.singular), }); } } else { setMessage({ tone: "error", text: "Couldn't reach the server." }); } } finally { setSaving(false); } }; const remove = async () => { if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return; try { await del(`/admin/${manifest.key}/${id}`); baseline.current = JSON.stringify(form); // nothing left to warn about navigate(`/admin/${manifest.key}`, { replace: true }); } catch (err) { setMessage({ tone: "error", text: err instanceof ApiError ? friendly(err.message, manifest.singular) : "Couldn't delete that.", }); } }; const visible = (when?: FieldCondition) => !when || getPath(form, when.path) === when.value; return (

{isNew ? `New ${manifest.singular}` : heading}

{/* Slug. An auto-id entity has nothing to ask for on create, and nothing editable afterwards — so it gets a plain line rather than a disabled box pretending to be a field. */} {autoId ? ( !isNew && (

{manifest.idLabel} #{String(form.id)} {updatedAt && <> · last saved {updatedAt}}

) ) : (
{ setSlugTouched(true); change("id", `${prefixOf(form)}${slugify(value)}`); }} /> {!isNew && updatedAt && (

Last saved {updatedAt}

)}
)} {/* Field groups */} {manifest.groups.filter((group) => visible(group.when)).map((group) => (

{group.legend}

{group.note &&

{group.note}

}
{group.fields.map((field) => ( change(field.path, value)} /> ))}
))} {/* Child collections. Awards own none, so the panel would be an empty white box — skip it rather than render it. */} {children.some((child) => visible(child.when)) && (
{children .filter((child) => visible(child.when)) .map((child) => ( setForm((prev) => ({ ...prev, [child.key]: rows }))} /> ))}
)} {/* Sticky action bar */}
{canWrite ? ( <> {!isNew && canDelete && ( )} ) : ( Read-only: your account can view this but not change it. )} {dirty && !saving && ( Unsaved changes )} {message && ( {message.text} {message.recover === "reload" && ( )} )}
); }