feat(admin): publishing-group resources (dispatches, roadmap, events)
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>
This commit is contained in:
parent
3aaa21e6af
commit
dd9ea68fab
10 changed files with 841 additions and 19 deletions
|
|
@ -1323,3 +1323,18 @@
|
||||||
background: rgba(185, 107, 88, 0.10);
|
background: rgba(185, 107, 88, 0.10);
|
||||||
color: var(--pigment-terracotta);
|
color: var(--pigment-terracotta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Embedded sub-form internals (pulse fieldset etc.) ──────────── */
|
||||||
|
.bs-pulse-embed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.bs-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.bs-field-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ let pillLabel: string | null = null;
|
||||||
let pillClass: string | null = null;
|
let pillClass: string | null = null;
|
||||||
if (kind === 'pill') {
|
if (kind === 'pill') {
|
||||||
const col = column as Extract<typeof column, { kind: 'pill' }>;
|
const col = column as Extract<typeof column, { kind: 'pill' }>;
|
||||||
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | undefined);
|
const raw = col.value ? col.value(item) : (item[column.key] as string | undefined);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const variant = col.pillVariants[raw];
|
const variant = col.pillVariants[raw];
|
||||||
if (variant) {
|
if (variant) {
|
||||||
|
|
@ -56,7 +56,7 @@ let relText: string | null = null;
|
||||||
let relEmpty: string | null = null;
|
let relEmpty: string | null = null;
|
||||||
if (kind === 'relative-date') {
|
if (kind === 'relative-date') {
|
||||||
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
|
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
|
||||||
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | null | undefined);
|
const raw = col.value ? col.value(item) : (item[column.key] as string | null | undefined);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
relText = relativeTime(raw);
|
relText = relativeTime(raw);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -68,7 +68,7 @@ if (kind === 'relative-date') {
|
||||||
let numberText: string | null = null;
|
let numberText: string | null = null;
|
||||||
if (kind === 'number') {
|
if (kind === 'number') {
|
||||||
const col = column as Extract<typeof column, { kind: 'number' }>;
|
const col = column as Extract<typeof column, { kind: 'number' }>;
|
||||||
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as number | null | undefined);
|
const raw = col.value ? col.value(item) : (item[column.key] as number | null | undefined);
|
||||||
numberText = raw == null ? '—' : String(raw);
|
numberText = raw == null ? '—' : String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ if (kind === 'number') {
|
||||||
let tags: string[] = [];
|
let tags: string[] = [];
|
||||||
if (kind === 'tag-list') {
|
if (kind === 'tag-list') {
|
||||||
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
|
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
|
||||||
tags = col.valueOf(item);
|
tags = col.value(item);
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import FieldRenderer from './FieldRenderer.astro';
|
import FieldRenderer from './FieldRenderer.astro';
|
||||||
|
import PulseSubForm from '../embeds/PulseSubForm.astro';
|
||||||
import type { Field, FieldContext, Resource } from '../resource-types';
|
import type { Field, FieldContext, Resource } from '../resource-types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -104,16 +105,13 @@ const formAction = Astro.url.pathname + Astro.url.search;
|
||||||
|
|
||||||
{embeds.length > 0 && embeds.map((embed) => {
|
{embeds.length > 0 && embeds.map((embed) => {
|
||||||
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
|
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
|
||||||
return show && (
|
if (!show) return null;
|
||||||
|
return (
|
||||||
<section class="bs-embed" data-embed={embed.key}>
|
<section class="bs-embed" data-embed={embed.key}>
|
||||||
<h3 class="bs-embed-title">{embed.title}</h3>
|
<h3 class="bs-embed-title">{embed.title}</h3>
|
||||||
{/*
|
{embed.component === 'pulse-sub-form' && (
|
||||||
* Embed components are resolved per-resource. The pulse-sub-form
|
<PulseSubForm item={item} />
|
||||||
* renderer is wired in when the dispatches resource lands in step 8.
|
)}
|
||||||
*/}
|
|
||||||
<p class="bs-embed-placeholder">
|
|
||||||
<em>{embed.component} renderer will be wired in step 8.</em>
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
101
src/admin/embeds/PulseSubForm.astro
Normal file
101
src/admin/embeds/PulseSubForm.astro
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* PulseSubForm — embedded fieldset inside the dispatch edit panel.
|
||||||
|
*
|
||||||
|
* Reads the dispatch's current pulse (if any) and renders editable fields.
|
||||||
|
* Submitted via the parent dispatch form with `pulse_*` prefixed names; the
|
||||||
|
* dispatches resource's ops.create/update read these out of ctx.formData.
|
||||||
|
*
|
||||||
|
* If pulse_question is blank on save, no pulse is attached. The status field
|
||||||
|
* intentionally isn't here — pulses follow their parent dispatch's lifecycle
|
||||||
|
* (draft → open on publish, → closed on archive).
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { getPulseById } from '../../lib/db';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The dispatch being edited, or null when creating. */
|
||||||
|
item: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
const pulseId = item?.pulse_id ? Number(item.pulse_id) : null;
|
||||||
|
const pulse = pulseId ? getPulseById(pulseId) : null;
|
||||||
|
|
||||||
|
const question = pulse?.question ?? '';
|
||||||
|
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
|
||||||
|
while (initialOptions.length < 2) initialOptions.push('');
|
||||||
|
|
||||||
|
function toDatetimeLocal(v: string | null | undefined): string {
|
||||||
|
if (!v) return '';
|
||||||
|
const s = String(v).replace(' ', 'T');
|
||||||
|
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="bs-pulse-embed">
|
||||||
|
<p class="bs-helper">
|
||||||
|
Attach a pulse by filling in the question and at least two options.
|
||||||
|
Leaving the question blank means no pulse on this dispatch.
|
||||||
|
Pulses open when the dispatch is published and close when it's archived.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bs-field">
|
||||||
|
<label class="bs-label" for="pulse_question">Question</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="pulse_question"
|
||||||
|
name="pulse_question"
|
||||||
|
class="bs-input"
|
||||||
|
value={question}
|
||||||
|
placeholder="What should we prioritise next?"
|
||||||
|
maxlength="240"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bs-field">
|
||||||
|
<label class="bs-label">Options</label>
|
||||||
|
<div class="bs-multitext" data-multitext="pulse_options" data-min="2" data-max="4">
|
||||||
|
<div class="bs-multitext-rows">
|
||||||
|
{initialOptions.map((opt, i) => (
|
||||||
|
<div class="bs-multitext-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="pulse_options"
|
||||||
|
class="bs-input"
|
||||||
|
value={opt}
|
||||||
|
placeholder={`Option ${i + 1}`}
|
||||||
|
maxlength="120"
|
||||||
|
/>
|
||||||
|
<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="bs-multitext-add">+ Add option</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bs-field-row">
|
||||||
|
<div class="bs-field">
|
||||||
|
<label class="bs-label" for="pulse_opens_at">Opens</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="pulse_opens_at"
|
||||||
|
name="pulse_opens_at"
|
||||||
|
class="bs-input"
|
||||||
|
value={toDatetimeLocal(pulse?.opens_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="bs-field">
|
||||||
|
<label class="bs-label" for="pulse_closes_at">Closes</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="pulse_closes_at"
|
||||||
|
name="pulse_closes_at"
|
||||||
|
class="bs-input"
|
||||||
|
value={toDatetimeLocal(pulse?.closes_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -135,22 +135,22 @@ export interface PillColumn<T> extends ColumnBase {
|
||||||
kind: 'pill';
|
kind: 'pill';
|
||||||
pillVariants: PillVariants;
|
pillVariants: PillVariants;
|
||||||
/** Override which value to look up in pillVariants (default = item[key]). */
|
/** Override which value to look up in pillVariants (default = item[key]). */
|
||||||
valueOf?: (item: T) => string;
|
value?: (item: T) => string;
|
||||||
}
|
}
|
||||||
export interface RelativeDateColumn<T> extends ColumnBase {
|
export interface RelativeDateColumn<T> extends ColumnBase {
|
||||||
kind: 'relative-date';
|
kind: 'relative-date';
|
||||||
/** Shown when the value is null/undefined. */
|
/** Shown when the value is null/undefined. */
|
||||||
emptyFallback?: string;
|
emptyFallback?: string;
|
||||||
valueOf?: (item: T) => string | null | undefined;
|
value?: (item: T) => string | null | undefined;
|
||||||
}
|
}
|
||||||
export interface NumberColumn<T> extends ColumnBase {
|
export interface NumberColumn<T> extends ColumnBase {
|
||||||
kind: 'number';
|
kind: 'number';
|
||||||
valueOf?: (item: T) => number | null | undefined;
|
value?: (item: T) => number | null | undefined;
|
||||||
}
|
}
|
||||||
/** Compact list of pills — for focus_tags, audience, etc. */
|
/** Compact list of pills — for focus_tags, audience, etc. */
|
||||||
export interface TagListColumn<T> extends ColumnBase {
|
export interface TagListColumn<T> extends ColumnBase {
|
||||||
kind: 'tag-list';
|
kind: 'tag-list';
|
||||||
valueOf: (item: T) => string[];
|
value: (item: T) => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Column<T> =
|
export type Column<T> =
|
||||||
|
|
@ -221,6 +221,12 @@ export interface FormConfig {
|
||||||
// ── Op context — passed to CRUD ops and actions ─────────────────────────────
|
// ── Op context — passed to CRUD ops and actions ─────────────────────────────
|
||||||
export interface OpContext {
|
export interface OpContext {
|
||||||
user: { id: number; role: string };
|
user: { id: number; role: string };
|
||||||
|
/**
|
||||||
|
* Raw POST FormData — opt-in escape hatch for resources whose form has
|
||||||
|
* embedded sub-forms (e.g. the pulse fieldset inside dispatches). Most
|
||||||
|
* resources ignore this and work off the typed `data` argument.
|
||||||
|
*/
|
||||||
|
formData?: FormData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CRUD operations ─────────────────────────────────────────────────────────
|
// ── CRUD operations ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
260
src/admin/resources/dispatches.ts
Normal file
260
src/admin/resources/dispatches.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
250
src/admin/resources/events.ts
Normal file
250
src/admin/resources/events.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Events resource.
|
||||||
|
*
|
||||||
|
* Slug auto-generates from title on create when blank; on edit it's a regular
|
||||||
|
* editable text field (changing it breaks any external links — admin's call).
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
getEventById,
|
||||||
|
getEventBySlug,
|
||||||
|
getAllEvents,
|
||||||
|
getEventRsvpCount,
|
||||||
|
type Event,
|
||||||
|
type EventKind,
|
||||||
|
} from '../../lib/db';
|
||||||
|
import { eventKindLabel } from '../../lib/format';
|
||||||
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueEventSlug(base: string): string {
|
||||||
|
let slug = base || 'event';
|
||||||
|
let n = 1;
|
||||||
|
while (getEventBySlug(slug)) {
|
||||||
|
n += 1;
|
||||||
|
slug = `${base}-${n}`;
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSqliteDatetime(s: string): string {
|
||||||
|
if (!s) return '';
|
||||||
|
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventsResource: Resource<Event> = {
|
||||||
|
key: 'events',
|
||||||
|
label: 'Events',
|
||||||
|
pluralLabel: 'Events',
|
||||||
|
singularLabel: 'Event',
|
||||||
|
groupKey: 'publishing',
|
||||||
|
description: 'Gatherings, dinners, virtual sessions — anything that shows up at /events.',
|
||||||
|
publicRoutePattern: (item) => `/events/${item.slug}`,
|
||||||
|
|
||||||
|
list: {
|
||||||
|
queryFn: () => getAllEvents(),
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
primary: true,
|
||||||
|
width: '2fr',
|
||||||
|
render: (item) => ({
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.location,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
label: 'Kind',
|
||||||
|
kind: 'pill',
|
||||||
|
width: '140px',
|
||||||
|
pillVariants: {
|
||||||
|
dinner: { label: 'Dinner', class: 'pill-decision' },
|
||||||
|
office_hours: { label: 'Studio hours', class: 'pill-update' },
|
||||||
|
summit: { label: 'Summit', class: 'pill-note' },
|
||||||
|
virtual: { label: 'Virtual', class: 'pill-bts' },
|
||||||
|
working_session: { label: 'Working session', class: 'pill-considering' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'starts_at',
|
||||||
|
label: 'Starts',
|
||||||
|
kind: 'relative-date',
|
||||||
|
width: '110px',
|
||||||
|
emptyFallback: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'capacity',
|
||||||
|
label: 'Capacity',
|
||||||
|
kind: 'number',
|
||||||
|
width: '90px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: 'All',
|
||||||
|
predicate: () => true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upcoming',
|
||||||
|
label: 'Upcoming',
|
||||||
|
predicate: (i) => new Date(i.starts_at).getTime() >= Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'past',
|
||||||
|
label: 'Past',
|
||||||
|
predicate: (i) => new Date(i.starts_at).getTime() < Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search by title or location…',
|
||||||
|
fields: ['title', 'location', 'description'],
|
||||||
|
},
|
||||||
|
defaultSort: { key: 'starts_at', direction: 'desc' },
|
||||||
|
pageSize: 25,
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
fields: [
|
||||||
|
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
||||||
|
{
|
||||||
|
key: 'slug',
|
||||||
|
label: 'Slug',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 80,
|
||||||
|
helperText: 'URL path under /events/. Leave blank on create to auto-generate from the title.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
label: 'Kind',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
options: (['dinner', 'office_hours', 'summit', 'virtual', 'working_session'] as EventKind[]).map(
|
||||||
|
(k) => ({ value: k, label: eventKindLabel(k) }),
|
||||||
|
),
|
||||||
|
defaultValue: 'dinner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
kind: 'textarea',
|
||||||
|
rows: 5,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'location',
|
||||||
|
label: 'Location',
|
||||||
|
kind: 'text',
|
||||||
|
required: true,
|
||||||
|
maxLength: 200,
|
||||||
|
helperText: 'Address, room, or video link.',
|
||||||
|
},
|
||||||
|
{ key: 'starts_at', label: 'Starts at', kind: 'datetime', required: true },
|
||||||
|
{ key: 'ends_at', label: 'Ends at', kind: 'datetime' },
|
||||||
|
{
|
||||||
|
key: 'capacity',
|
||||||
|
label: 'Capacity',
|
||||||
|
kind: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 999,
|
||||||
|
helperText: 'Leave blank for uncapped.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audience',
|
||||||
|
label: 'Audience',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 200,
|
||||||
|
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duration_label',
|
||||||
|
label: 'Duration label',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 40,
|
||||||
|
helperText: 'Optional display label like "90 min" or "Half day".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action_label',
|
||||||
|
label: 'Action button label',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 40,
|
||||||
|
helperText: 'Override the default per-kind CTA (e.g. "Reserve your table").',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'photo_url',
|
||||||
|
label: 'Photo URL',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 400,
|
||||||
|
helperText: 'Optional hero image for the event detail page.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notes_url',
|
||||||
|
label: 'Notes URL',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 400,
|
||||||
|
helperText: 'Optional link to event notes published after the gathering.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
ops: {
|
||||||
|
getById: (id) => getEventById(id),
|
||||||
|
|
||||||
|
create: (data, ctx) => {
|
||||||
|
const rawSlug = ((data.slug as string) ?? '').trim();
|
||||||
|
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
|
||||||
|
return createEvent({
|
||||||
|
slug,
|
||||||
|
title: String(data.title),
|
||||||
|
kind: data.kind as EventKind,
|
||||||
|
description: String(data.description),
|
||||||
|
location: String(data.location),
|
||||||
|
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||||
|
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||||
|
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||||
|
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||||
|
audience: ((data.audience as string) ?? '').trim() || null,
|
||||||
|
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||||
|
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||||
|
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||||
|
created_by: ctx.user.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (id, data) => {
|
||||||
|
updateEvent(id, {
|
||||||
|
title: String(data.title),
|
||||||
|
kind: data.kind as EventKind,
|
||||||
|
description: String(data.description),
|
||||||
|
location: String(data.location),
|
||||||
|
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||||
|
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||||
|
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||||
|
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||||
|
audience: ((data.audience as string) ?? '').trim() || null,
|
||||||
|
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||||
|
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||||
|
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (id) => deleteEvent(id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal use, not exported on the resource — used by future row subtitles
|
||||||
|
// if we want RSVP counts in the list view. Left here as a marker.
|
||||||
|
export { getEventRsvpCount };
|
||||||
|
|
@ -2,14 +2,20 @@
|
||||||
* Resource registry — single source of truth for sidebar navigation.
|
* Resource registry — single source of truth for sidebar navigation.
|
||||||
*
|
*
|
||||||
* Groups are populated incrementally across steps 8–10 of the Backstage
|
* Groups are populated incrementally across steps 8–10 of the Backstage
|
||||||
* rebuild. Empty registration is intentional during the shell-only phase;
|
* rebuild. The display order inside each group matches sidebar order.
|
||||||
* AdminLayout renders the empty state until the first resource lands.
|
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import type { ResourceGroup } from '../resource-types';
|
import type { ResourceGroup } from '../resource-types';
|
||||||
|
import { dispatchesResource } from './dispatches';
|
||||||
|
import { roadmapResource } from './roadmap';
|
||||||
|
import { eventsResource } from './events';
|
||||||
|
|
||||||
export const groups: ResourceGroup[] = [
|
export const groups: ResourceGroup[] = [
|
||||||
{ key: 'publishing', label: 'Publishing', resources: [] },
|
{
|
||||||
|
key: 'publishing',
|
||||||
|
label: 'Publishing',
|
||||||
|
resources: [dispatchesResource, roadmapResource, eventsResource],
|
||||||
|
},
|
||||||
{ key: 'council', label: 'The council', resources: [] },
|
{ key: 'council', label: 'The council', resources: [] },
|
||||||
{ key: 'system', label: 'System', resources: [] },
|
{ key: 'system', label: 'System', resources: [] },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
185
src/admin/resources/roadmap.ts
Normal file
185
src/admin/resources/roadmap.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Roadmap items resource.
|
||||||
|
*
|
||||||
|
* Attributed members come in via a multi-select-async loading all users; the
|
||||||
|
* update handler calls setRoadmapAttributions() after the basic update so the
|
||||||
|
* pivot table reflects the current selection.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRoadmapItem,
|
||||||
|
updateRoadmapItem,
|
||||||
|
deleteRoadmapItem,
|
||||||
|
getRoadmapItem,
|
||||||
|
getAllRoadmapItems,
|
||||||
|
getAllUsersPublic,
|
||||||
|
setRoadmapAttributions,
|
||||||
|
type RoadmapItemWithAttribution,
|
||||||
|
type RoadmapStatus,
|
||||||
|
} from '../../lib/db';
|
||||||
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
|
export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
|
key: 'roadmap',
|
||||||
|
label: 'Roadmap',
|
||||||
|
pluralLabel: 'Roadmap items',
|
||||||
|
singularLabel: 'Roadmap item',
|
||||||
|
groupKey: 'publishing',
|
||||||
|
description: 'The route members see at /roadmap — what is shipping, in beta, exploring, or considered.',
|
||||||
|
|
||||||
|
list: {
|
||||||
|
queryFn: () => getAllRoadmapItems(),
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
primary: true,
|
||||||
|
width: '2fr',
|
||||||
|
render: (item) => ({
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.description.slice(0, 80) + (item.description.length > 80 ? '…' : ''),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
kind: 'pill',
|
||||||
|
width: '120px',
|
||||||
|
pillVariants: {
|
||||||
|
shipping: { label: 'Shipping', class: 'pill-shipping' },
|
||||||
|
in_beta: { label: 'In beta', class: 'pill-in-beta' },
|
||||||
|
exploring: { label: 'Exploring', class: 'pill-exploring' },
|
||||||
|
considering: { label: 'Considering', class: 'pill-considering' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'target',
|
||||||
|
label: 'Target',
|
||||||
|
width: '140px',
|
||||||
|
render: (item) => ({ title: item.target ?? '—' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display_order',
|
||||||
|
label: 'Order',
|
||||||
|
kind: 'number',
|
||||||
|
width: '70px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||||
|
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
|
||||||
|
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
|
||||||
|
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
|
||||||
|
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
|
||||||
|
],
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search by title or description…',
|
||||||
|
fields: ['title', 'description'],
|
||||||
|
},
|
||||||
|
defaultSort: { key: 'display_order', direction: 'asc' },
|
||||||
|
pageSize: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
fields: [
|
||||||
|
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
kind: 'textarea',
|
||||||
|
rows: 4,
|
||||||
|
required: true,
|
||||||
|
helperText: 'Shown on hover in the /roadmap route. Keep it to a sentence or two.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'shipping', label: 'Shipping' },
|
||||||
|
{ value: 'in_beta', label: 'In beta' },
|
||||||
|
{ value: 'exploring', label: 'Exploring' },
|
||||||
|
{ value: 'considering', label: 'Considering' },
|
||||||
|
],
|
||||||
|
defaultValue: 'exploring',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'target',
|
||||||
|
label: 'Target',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 80,
|
||||||
|
helperText: 'Free-form quarter or date, e.g. "Q2 2026" or "Late May".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display_order',
|
||||||
|
label: 'Display order',
|
||||||
|
kind: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 999,
|
||||||
|
defaultValue: 0,
|
||||||
|
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metadata_text',
|
||||||
|
label: 'Hover metadata',
|
||||||
|
kind: 'text',
|
||||||
|
maxLength: 120,
|
||||||
|
helperText: 'Short narrative cue shown on hover, e.g. "shipped 3 days ago".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attributed_members',
|
||||||
|
label: 'Attributed members',
|
||||||
|
kind: 'multi-select-async',
|
||||||
|
loadOptions: () =>
|
||||||
|
getAllUsersPublic().map((u) => ({ value: u.id, label: u.name })),
|
||||||
|
helperText: 'Members credited for this item. Surfaces on their public profile and on the milestone card.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
ops: {
|
||||||
|
getById: (id) => {
|
||||||
|
const item = getRoadmapItem(id);
|
||||||
|
if (!item) return null;
|
||||||
|
// Expose attributed user-ids under the key the multi-select expects.
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
attributed_members: item.attributed.map((u) => u.id),
|
||||||
|
} as unknown as RoadmapItemWithAttribution;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: (data) => {
|
||||||
|
const id = createRoadmapItem({
|
||||||
|
title: String(data.title),
|
||||||
|
description: String(data.description),
|
||||||
|
status: data.status as RoadmapStatus,
|
||||||
|
target: ((data.target as string) ?? '').trim() || null,
|
||||||
|
display_order: Number(data.display_order ?? 0),
|
||||||
|
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||||
|
});
|
||||||
|
const userIds = Array.isArray(data.attributed_members)
|
||||||
|
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||||
|
: [];
|
||||||
|
if (userIds.length > 0) setRoadmapAttributions(id, userIds);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (id, data) => {
|
||||||
|
updateRoadmapItem(id, {
|
||||||
|
title: String(data.title),
|
||||||
|
description: String(data.description),
|
||||||
|
status: data.status as RoadmapStatus,
|
||||||
|
target: ((data.target as string) ?? '').trim() || null,
|
||||||
|
display_order: Number(data.display_order ?? 0),
|
||||||
|
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||||
|
});
|
||||||
|
const userIds = Array.isArray(data.attributed_members)
|
||||||
|
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||||
|
: [];
|
||||||
|
setRoadmapAttributions(id, userIds);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (id) => deleteRoadmapItem(id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -92,6 +92,7 @@ let resubmitValues: Record<string, unknown> | null = null;
|
||||||
if (Astro.request.method === 'POST') {
|
if (Astro.request.method === 'POST') {
|
||||||
const formData = await Astro.request.formData();
|
const formData = await Astro.request.formData();
|
||||||
const action = String(formData.get('_action') ?? 'save');
|
const action = String(formData.get('_action') ?? 'save');
|
||||||
|
opCtx.formData = formData;
|
||||||
|
|
||||||
const editIdParam = Astro.url.searchParams.get('edit');
|
const editIdParam = Astro.url.searchParams.get('edit');
|
||||||
const editId =
|
const editId =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue