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>
471 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|