The Backstage rebuild's first three resource configs. /admin/dispatches, /admin/roadmap, and /admin/events now resolve through the dynamic route with full list views, edit panels, and the publish/archive actions. - src/admin/resources/dispatches.ts — kind/status/author/excerpt/body fields, embedded pulse sub-form (pulse_question + multi-text options + opens/closes datetimes), publish/archive actions, notifyCount on drafts so the sidebar lights up terracotta until they ship. - src/admin/resources/roadmap.ts — title/description/status/target/ display_order/metadata_text plus a multi-select-async for attributed members. ops.update writes via setRoadmapAttributions() after the basic save so the pivot table stays in sync. - src/admin/resources/events.ts — full event fields; ops.create auto-generates a unique slug from the title when blank. - src/admin/embeds/PulseSubForm.astro — reads the dispatch's current pulse via getPulseById(), renders question + options + opens/closes. Pulses follow their parent dispatch's lifecycle (draft → open on publish, → closed on archive); no status field of their own. - src/admin/components/ResourceEditPanel.astro — dispatches on embed.component, renders PulseSubForm for 'pulse-sub-form'. - src/admin/resource-types.ts — renamed column .valueOf to .value (collision with Object.prototype.valueOf was breaking TS structural matching); OpContext now optionally carries the raw FormData so resources with sub-forms can read embed fields. - src/pages/admin/[resource].astro — passes formData into opCtx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
/* ---------------------------------------------------------------------------
|
|
* Dispatches resource — the canonical example of the resource pattern.
|
|
*
|
|
* The optional pulse sub-form is read out of ctx.formData by the create/update
|
|
* handlers (pulse_question / pulse_options[] / pulse_opens_at / pulse_closes_at).
|
|
* Status changes ride on the same Save submit: when the form's chosen status
|
|
* differs from the current one, publishDispatch / archiveDispatch fire after
|
|
* the content save.
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
import {
|
|
createDispatch,
|
|
updateDispatch,
|
|
publishDispatch,
|
|
archiveDispatch,
|
|
deleteDispatch,
|
|
getDispatchById,
|
|
getAllDispatchesForAdmin,
|
|
getAllUsersPublic,
|
|
type DispatchKind,
|
|
type DispatchPollInput,
|
|
type DispatchStatus,
|
|
type DispatchWithAuthor,
|
|
} from '../../lib/db';
|
|
import { dispatchSlug } from '../../lib/format';
|
|
import type { FieldContext, Resource } from '../resource-types';
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
|
|
function toSqliteDatetime(s: string): string {
|
|
if (!s) return '';
|
|
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
|
}
|
|
|
|
function nowSqlite(): string {
|
|
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
function plusDaysSqlite(days: number): string {
|
|
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
|
|
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
|
|
const question = String(formData.get('pulse_question') ?? '').trim();
|
|
if (!question) return null;
|
|
const options = formData
|
|
.getAll('pulse_options')
|
|
.map((v) => String(v).trim())
|
|
.filter(Boolean)
|
|
.slice(0, 4);
|
|
if (options.length < 2) return null;
|
|
const opens_at =
|
|
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
|
|
const closes_at =
|
|
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
|
|
return { question, options, opens_at, closes_at };
|
|
}
|
|
|
|
// ── Resource ────────────────────────────────────────────────────────────────
|
|
|
|
export const dispatchesResource: Resource<DispatchWithAuthor> = {
|
|
key: 'dispatches',
|
|
label: 'Dispatches',
|
|
pluralLabel: 'Dispatches',
|
|
singularLabel: 'Dispatch',
|
|
groupKey: 'publishing',
|
|
description: 'Updates, decisions, notes — the public record of pilot progress.',
|
|
publicRoutePattern: (item) => `/dispatches/${dispatchSlug(item)}`,
|
|
|
|
list: {
|
|
queryFn: () => getAllDispatchesForAdmin(),
|
|
columns: [
|
|
{
|
|
key: 'title',
|
|
label: 'Title',
|
|
primary: true,
|
|
width: '2fr',
|
|
render: (item) => ({
|
|
title: item.title,
|
|
subtitle: item.author_name,
|
|
}),
|
|
},
|
|
{
|
|
key: 'kind',
|
|
label: 'Kind',
|
|
kind: 'pill',
|
|
width: '140px',
|
|
pillVariants: {
|
|
decision: { label: 'Decision', class: 'pill-decision' },
|
|
update: { label: 'Update', class: 'pill-update' },
|
|
note: { label: 'Note', class: 'pill-note' },
|
|
behind_the_scenes: { label: 'Behind the scenes', class: 'pill-bts' },
|
|
},
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
kind: 'pill',
|
|
width: '110px',
|
|
pillVariants: {
|
|
draft: { label: 'Draft', class: 'pill-draft' },
|
|
published: { label: 'Published', class: 'pill-published' },
|
|
archived: { label: 'Archived', class: 'pill-archived' },
|
|
},
|
|
},
|
|
{
|
|
key: 'updated_at',
|
|
label: 'Updated',
|
|
kind: 'relative-date',
|
|
width: '110px',
|
|
emptyFallback: '—',
|
|
},
|
|
],
|
|
filters: [
|
|
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
|
{ key: 'published', label: 'Published', predicate: (i) => i.status === 'published' },
|
|
{ key: 'drafts', label: 'Drafts', predicate: (i) => i.status === 'draft' },
|
|
{ key: 'archived', label: 'Archived', predicate: (i) => i.status === 'archived' },
|
|
],
|
|
search: {
|
|
placeholder: 'Search by title or body…',
|
|
fields: ['title', 'body'],
|
|
},
|
|
defaultSort: { key: 'updated_at', direction: 'desc' },
|
|
pageSize: 25,
|
|
},
|
|
|
|
// Drafts in the sidebar light up terracotta until they're published.
|
|
notifyCount: {
|
|
count: (items) => items.filter((i) => i.status === 'draft').length,
|
|
},
|
|
|
|
form: {
|
|
fields: [
|
|
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
|
{
|
|
key: 'kind',
|
|
label: 'Kind',
|
|
kind: 'select',
|
|
options: [
|
|
{ value: 'decision', label: 'Decision' },
|
|
{ value: 'update', label: 'Update' },
|
|
{ value: 'note', label: 'Note' },
|
|
{ value: 'behind_the_scenes', label: 'Behind the scenes' },
|
|
],
|
|
defaultValue: 'note',
|
|
},
|
|
{
|
|
key: 'author_id',
|
|
label: 'Author',
|
|
kind: 'select-async',
|
|
required: true,
|
|
loadOptions: () =>
|
|
getAllUsersPublic()
|
|
.filter((u) => u.role === 'fenja')
|
|
.map((u) => ({ value: u.id, label: u.name })),
|
|
defaultValue: (ctx: FieldContext) => ctx.actingUserId,
|
|
},
|
|
{
|
|
key: 'excerpt',
|
|
label: 'Excerpt',
|
|
kind: 'textarea',
|
|
rows: 4,
|
|
helperText:
|
|
'Two to four sentences. The first sentence becomes the lead paragraph on the dispatch banner. Leave blank to fall back to the first ~200 chars of body.',
|
|
},
|
|
{
|
|
key: 'body',
|
|
label: 'Body (markdown)',
|
|
kind: 'markdown',
|
|
rows: 14,
|
|
required: true,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status on save',
|
|
kind: 'select',
|
|
options: [
|
|
{ value: 'draft', label: 'Draft (hidden from members)' },
|
|
{ value: 'published', label: 'Published (visible immediately)' },
|
|
{ value: 'archived', label: 'Archived (hidden from members, kept here)' },
|
|
],
|
|
defaultValue: 'draft',
|
|
helperText:
|
|
'Switching from draft to published is the same as clicking the Publish action — the dispatch becomes visible to all members.',
|
|
},
|
|
],
|
|
embeds: [{ key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }],
|
|
},
|
|
|
|
ops: {
|
|
getById: (id) => getDispatchById(id),
|
|
|
|
create: (data, ctx) => {
|
|
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
|
|
const status = (data.status as DispatchStatus) ?? 'draft';
|
|
return createDispatch({
|
|
title: String(data.title),
|
|
body: String(data.body),
|
|
excerpt: ((data.excerpt as string) ?? '').trim() || null,
|
|
kind: data.kind as DispatchKind,
|
|
author_id: Number(data.author_id),
|
|
status,
|
|
poll,
|
|
});
|
|
},
|
|
|
|
update: (id, data, ctx) => {
|
|
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
|
|
const current = getDispatchById(id);
|
|
if (!current) throw new Error(`Dispatch ${id} not found`);
|
|
|
|
updateDispatch(id, {
|
|
title: String(data.title),
|
|
body: String(data.body),
|
|
excerpt: ((data.excerpt as string) ?? '').trim() || null,
|
|
kind: data.kind as DispatchKind,
|
|
author_id: Number(data.author_id),
|
|
poll,
|
|
// Only flag pollExplicit when a question was actually submitted.
|
|
// Empty pulse fields leave the existing pulse alone.
|
|
pollExplicit: poll !== null,
|
|
});
|
|
|
|
// Status transitions ride on the save submit.
|
|
const desiredStatus = (data.status as DispatchStatus) ?? current.status;
|
|
if (desiredStatus !== current.status) {
|
|
if (desiredStatus === 'published') publishDispatch(id);
|
|
else if (desiredStatus === 'archived') archiveDispatch(id);
|
|
// 'draft' from another state is a no-op — there's no "unpublish".
|
|
}
|
|
},
|
|
|
|
delete: (id) => deleteDispatch(id),
|
|
},
|
|
|
|
actions: [
|
|
{
|
|
key: 'publish',
|
|
label: 'Publish now',
|
|
visibleWhen: (item) => item.status === 'draft',
|
|
confirmText: 'Publish this dispatch to all members?',
|
|
handler: (id) => {
|
|
publishDispatch(id);
|
|
},
|
|
},
|
|
{
|
|
key: 'archive',
|
|
label: 'Archive',
|
|
visibleWhen: (item) => item.status === 'published',
|
|
destructive: true,
|
|
confirmText: 'Archive this dispatch? It will be hidden from members.',
|
|
handler: (id) => {
|
|
archiveDispatch(id);
|
|
},
|
|
},
|
|
],
|
|
};
|