feat(admin): /admin/[resource] dynamic route + POST dispatch
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=<id> 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. - <action.key>: looks up the action in resource.actions and runs its handler, redirects with ?msg=action_<key>. 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) <noreply@anthropic.com>
This commit is contained in:
parent
09a10061b2
commit
3aaa21e6af
2 changed files with 251 additions and 0 deletions
|
|
@ -1299,3 +1299,27 @@
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.bs-panel { width: 100vw; max-width: 100vw; }
|
.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);
|
||||||
|
}
|
||||||
|
|
|
||||||
227
src/pages/admin/[resource].astro
Normal file
227
src/pages/admin/[resource].astro
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* /admin/<resource> — 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=<key> 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<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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_<key> is rendered as
|
||||||
|
// "<action.label> 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<string, string>)[rawMsg] ?? null;
|
||||||
|
}
|
||||||
|
const flash = formError ?? flashTextFor(msg);
|
||||||
|
const flashKind = formError ? 'error' : 'success';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout
|
||||||
|
title={pageTitle}
|
||||||
|
groups={groups}
|
||||||
|
activeResourceKey={resource.key}
|
||||||
|
>
|
||||||
|
{flash && (
|
||||||
|
<div class:list={['bs-flash', flashKind]} role="status">{flash}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResourceListView resource={resource} groups={groups} />
|
||||||
|
|
||||||
|
{showPanel && resource.form && (
|
||||||
|
<ResourceEditPanel
|
||||||
|
resource={resource}
|
||||||
|
item={editingItem}
|
||||||
|
formValues={resubmitValues ?? undefined}
|
||||||
|
errors={errors}
|
||||||
|
actingUserId={user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AdminLayout>
|
||||||
Loading…
Add table
Reference in a new issue