From 3aaa21e6af998f85f138d500358c1d7d82d8bfbe Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 16:15:17 +0200 Subject: [PATCH] feat(admin): /admin/[resource] dynamic route + POST dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production route every Backstage resource lives under. Resolves the resource from the URL segment against the registry, gates on user.role === 'fenja', and renders the AdminLayout shell with the ResourceListView + (optionally) ResourceEditPanel. POST dispatch keyed by _action: - save: parses formdata per field.kind (multi-text/multi-select-async use getAll(), number coerces, others coerce to string), validates via validateForResource, then routes to ops.update(id) when ?edit= is set or ops.create() when ?new=1. Redirects with ?msg=saved | ?msg=created. On failure, re-renders the panel with errors + the submitted values. - delete: calls ops.delete(id), redirects with ?msg=deleted. - : looks up the action in resource.actions and runs its handler, redirects with ?msg=action_. 404s when the resource key isn't in the registry — most keys won't resolve until steps 8-10 land. A small .bs-flash banner above the list surfaces the ?msg= text (or the error message after a failed save). Old /admin (?tab=...) continues to work alongside. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/admin/admin.css | 24 ++++ src/pages/admin/[resource].astro | 227 +++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 src/pages/admin/[resource].astro diff --git a/src/admin/admin.css b/src/admin/admin.css index 2afeb88..d44b19f 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -1299,3 +1299,27 @@ @media (max-width: 767px) { .bs-panel { width: 100vw; max-width: 100vw; } } + +/* =========================================================================== + * Flash banner — step 7 + * + * Lives above the list view, surfaces the ?msg= flash after a redirect. + * Two variants: success (default) and error (after a save failure). + * ========================================================================= */ + +.bs-flash { + padding: 10px var(--space-5); + border-radius: var(--radius-sm); + font-family: var(--font-sans); + font-size: 12px; + letter-spacing: var(--tracking-wide); + margin-bottom: var(--space-4); +} +.bs-flash.success { + background: rgba(109, 140, 124, 0.10); + color: #5a7268; +} +.bs-flash.error { + background: rgba(185, 107, 88, 0.10); + color: var(--pigment-terracotta); +} diff --git a/src/pages/admin/[resource].astro b/src/pages/admin/[resource].astro new file mode 100644 index 0000000..a026209 --- /dev/null +++ b/src/pages/admin/[resource].astro @@ -0,0 +1,227 @@ +--- +/* --------------------------------------------------------------------------- + * /admin/ — the production dynamic admin route. + * + * Resolves the resource from the URL segment, gates on user.role === 'fenja', + * dispatches POSTs (save / delete / action key) through validateForResource + * and resource.ops, redirects with a ?msg= flash on success. + * + * 404s when the resource key is not registered — step 8 onward populates + * the registry; until then most resource keys won't resolve. + * ------------------------------------------------------------------------- */ + +import AdminLayout from '../../admin/components/AdminLayout.astro'; +import ResourceListView from '../../admin/components/ResourceListView.astro'; +import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro'; +import { groups } from '../../admin/resources'; +import { validateForResource, type ValidationErrors } from '../../admin/validate'; +import type { + Field, + OpContext, + Resource, +} from '../../admin/resource-types'; + +// ── Auth gate ───────────────────────────────────────────────────────────── +const user = Astro.locals.user; +if (user.role !== 'fenja') return Astro.redirect('/'); + +// ── Resolve resource from URL segment ───────────────────────────────────── +const resourceKey = Astro.params.resource; +const allResources = groups.flatMap((g) => g.resources); +const resource = allResources.find((r) => r.key === resourceKey) as + | Resource + | undefined; + +if (!resource) { + return new Response('Resource not found', { status: 404 }); +} + +const resourceBase = `/admin/${resource.key}`; +const opCtx: OpContext = { user: { id: user.id, role: user.role } }; + +// ── Form-data → typed record (driven by the field configs) ──────────────── +function parseFormData( + formData: FormData, + fields: Field[], +): Record { + const out: Record = {}; + for (const field of fields) { + if (field.readOnly) continue; + + switch (field.kind) { + case 'multi-text': { + out[field.key] = formData + .getAll(field.key) + .map((v) => String(v)) + .filter((v) => v.trim() !== ''); + break; + } + case 'multi-select-async': { + out[field.key] = formData.getAll(field.key).map((v) => { + const s = String(v); + const n = Number(s); + return Number.isFinite(n) && s !== '' ? n : s; + }); + break; + } + case 'number': { + const v = formData.get(field.key); + if (v == null || v === '') { + out[field.key] = null; + } else { + const n = Number(v); + out[field.key] = Number.isFinite(n) ? n : v; + } + break; + } + default: { + const v = formData.get(field.key); + out[field.key] = v == null ? '' : String(v); + } + } + } + return out; +} + +// ── State that survives a failed POST (so the panel re-fills) ───────────── +let errors: ValidationErrors = {}; +let formError: string | null = null; +let resubmitValues: Record | null = null; + +// ── POST dispatch ──────────────────────────────────────────────────────── +if (Astro.request.method === 'POST') { + const formData = await Astro.request.formData(); + const action = String(formData.get('_action') ?? 'save'); + + const editIdParam = Astro.url.searchParams.get('edit'); + const editId = + editIdParam && Number.isFinite(Number(editIdParam)) + ? Number(editIdParam) + : null; + + // ── save ────────────────────────────────────────────────────────────── + if (action === 'save') { + if (!resource.form) { + return new Response('Resource is read-only', { status: 403 }); + } + + const data = parseFormData(formData, resource.form.fields); + const existingItem = + editId !== null && resource.ops.getById + ? ((await resource.ops.getById(editId)) as Record | null) + : null; + + errors = validateForResource({ + resource, + data, + item: existingItem, + actingUserId: user.id, + }); + + if (Object.keys(errors).length === 0) { + try { + if (editId !== null && resource.ops.update) { + await resource.ops.update(editId, data, opCtx); + return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved`); + } + if (editId === null && resource.ops.create) { + const newId = await resource.ops.create(data, opCtx); + return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created`); + } + return Astro.redirect(`${resourceBase}?msg=saved`); + } catch (err) { + formError = err instanceof Error ? err.message : 'Save failed'; + resubmitValues = data; + } + } else { + resubmitValues = data; + } + } + + // ── delete ──────────────────────────────────────────────────────────── + else if (action === 'delete') { + if (editId !== null && resource.ops.delete) { + try { + await resource.ops.delete(editId, opCtx); + return Astro.redirect(`${resourceBase}?msg=deleted`); + } catch (err) { + formError = err instanceof Error ? err.message : 'Delete failed'; + } + } + } + + // ── custom action ───────────────────────────────────────────────────── + else { + const customAction = resource.actions?.find((a) => a.key === action); + if (customAction && editId !== null) { + try { + await customAction.handler(editId, opCtx); + return Astro.redirect( + `${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}`, + ); + } catch (err) { + formError = err instanceof Error ? err.message : 'Action failed'; + } + } else { + return new Response('Unknown action', { status: 400 }); + } + } +} + +// ── GET / failed-POST render ────────────────────────────────────────────── +const isNew = Astro.url.searchParams.get('new') === '1'; +const editIdRaw = Astro.url.searchParams.get('edit'); +const editId = + editIdRaw && Number.isFinite(Number(editIdRaw)) ? Number(editIdRaw) : null; + +const editingItem = + editId !== null && resource.ops.getById + ? ((await resource.ops.getById(editId)) as Record | null) + : null; + +const showPanel = resource.form !== null && (isNew || editingItem !== null); + +const msg = Astro.url.searchParams.get('msg'); +const pageTitle = `${resource.pluralLabel} — Backstage`; + +// Friendly flash text. Anything action_ is rendered as +// " done." using the resource's action label. +function flashTextFor(rawMsg: string | null): string | null { + if (!rawMsg) return null; + if (formError) return formError; + if (rawMsg.startsWith('action_')) { + const key = rawMsg.slice('action_'.length); + const action = resource!.actions?.find((a) => a.key === key); + return action ? `${action.label}.` : null; + } + return ({ + saved: 'Saved.', + created: 'Created.', + deleted: 'Deleted.', + } as Record)[rawMsg] ?? null; +} +const flash = formError ?? flashTextFor(msg); +const flashKind = formError ? 'error' : 'success'; +--- + + + {flash && ( +
{flash}
+ )} + + + + {showPanel && resource.form && ( + + )} +