v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -23,6 +23,16 @@
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
@ -37,6 +47,7 @@ import { get, post, patch, del, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.tsx";
import { ADMIN_ENTITIES, slugify } 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
@ -56,7 +67,14 @@ export default function EntityEdit() {
const { user } = useAuth();
const isNew = id === "new";
const canWrite = user?.role === "admin";
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
@ -92,8 +110,9 @@ export default function EntityEdit() {
};
// The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board".
const headingPath = slugPaths[slugPaths.length - 1];
// 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;
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
@ -120,7 +139,9 @@ export default function EntityEdit() {
// 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.
const blank = { id: "" };
// No id key for an auto entity: the table assigns it, and
// sending "" would be an explicit value rather than an absence.
const blank = autoId ? {} : { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
@ -139,7 +160,7 @@ export default function EntityEdit() {
} finally {
setLoading(false);
}
}, [manifest, id, isNew, navigate]);
}, [manifest, id, isNew, autoId, navigate]);
useEffect(() => {
load();
@ -288,7 +309,19 @@ export default function EntityEdit() {
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* Slug */}
{/* 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} #{form.id}
{form.updated_at && <> · last saved {form.updated_at}</>}
</p>
</div>
)
) : (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
@ -317,6 +350,7 @@ export default function EntityEdit() {
</p>
)}
</div>
)}
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
@ -332,6 +366,7 @@ export default function EntityEdit() {
<Field
key={field.path}
field={field}
row={form}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
@ -376,7 +411,7 @@ export default function EntityEdit() {
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && (
{!isNew && canDelete && (
<button
type="button"
onClick={remove}
@ -388,7 +423,7 @@ export default function EntityEdit() {
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes.
Read-only: your account can view this but not change it.
</span>
)}