NGU-Web/src/pages/admin/EntityEdit.tsx
Zaldimmar 2428f4412a Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:13:46 -05:00

471 lines
18 KiB
TypeScript

/* ═══════════════════════════════════════════════════════════════
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<AdminRow | null>(null);
const [options, setOptions] = useState<AdminOptions>({});
const [errors, setErrors] = useState<Partial<FieldErrors>>({});
const [message, setMessage] = useState<Notice | null>(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<string | null>(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<RowResponse>(`/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 <p className="text-[#4a6b72]">No such thing to edit.</p>;
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
/* ── 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<RowResponse>(`/admin/${manifest.key}`, form)
: await patch<RowResponse>(`/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 (
<div className="pb-24">
<button
type="button"
onClick={() => leave(`/admin/${manifest.key}`)}
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
>
← {manifest.label}
</button>
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* 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 && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
{manifest.idLabel} #{String(form.id)}
{updatedAt && <> · last saved {updatedAt}</>}
</p>
</div>
)
) : (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
path: "id",
label: manifest.idLabel,
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
prefixPending: isNew && !prefixOf(form),
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
readOnly: !isNew,
help: !isNew
? "Fixed once created — links and content blocks reference it."
: qualifierPaths.length
? "The prefix comes from the organization. Type the rest."
: "Lowercase, hyphens, no spaces. Can't be changed later.",
}}
value={isNew ? tailOf(form) : form.id}
error={errors.id}
onChange={(value) => {
setSlugTouched(true);
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && updatedAt && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {updatedAt}
</p>
)}
</div>
)}
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
<section
key={group.legend}
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
>
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
<div className="mt-4">
<FieldGrid>
{group.fields.map((field) => (
<Field
key={field.path}
field={field}
row={form}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
onChange={(value) => change(field.path, value)}
/>
))}
</FieldGrid>
</div>
</section>
))}
{/* 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)) && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
{children
.filter((child) => visible(child.when))
.map((child) => (
<Repeater
key={child.key}
spec={child}
rows={form[child.key] as AdminRow[] | undefined}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
/>
))}
</div>
)}
{/* Sticky action bar */}
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
{canWrite ? (
<>
<button
type="button"
onClick={save}
disabled={saving || !dirty}
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && canDelete && (
<button
type="button"
onClick={remove}
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can view this but not change it.
</span>
)}
{dirty && !saving && (
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
)}
{message && (
<span
role="status"
className={`flex items-center gap-2 text-sm ${
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
}`}
>
{message.text}
{message.recover === "reload" && (
<button
type="button"
onClick={load}
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
>
Reload
</button>
)}
</span>
)}
</div>
</div>
</div>
);
}