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) {
|
||||
.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