diff --git a/src/admin/resource-types.ts b/src/admin/resource-types.ts new file mode 100644 index 0000000..60aa094 --- /dev/null +++ b/src/admin/resource-types.ts @@ -0,0 +1,295 @@ +/* --------------------------------------------------------------------------- + * Resource type definitions for the Backstage admin surface. + * + * Every admin-managed entity is declared as a single Resource object. + * The shared components (AdminLayout, ResourceListView, ResourceEditPanel) + * consume these objects and never know about specific entities. + * + * Adding a new entity = write a Resource config + register it. That is the + * load-bearing invariant of the rebuild. + * ------------------------------------------------------------------------- */ + +// ── Option (used for select / select-async / multi-select-async) ──────────── +export interface Option { + value: V; + label: string; +} + +// ── Pill variants (status/kind columns + pill display elsewhere) ──────────── +export interface PillVariant { + label: string; + /** CSS class defined in src/admin/admin.css — e.g. 'pill-published'. */ + class: string; +} +export type PillVariants = Record; + +// ── Field context — passed to visibleWhen / defaultValue resolvers ────────── +export interface FieldContext { + /** Current form values keyed by field.key. */ + formValues: Record; + /** The item being edited, or null on create. */ + item: Record | null; + /** Acting admin's user id, available for current-user defaults. */ + actingUserId: number; +} + +// ── Fields ────────────────────────────────────────────────────────────────── +interface FieldBase { + key: string; + label: string; + required?: boolean; + helperText?: string; + /** Default for create. Can be a literal or a resolver receiving FieldContext. */ + defaultValue?: unknown | ((ctx: FieldContext) => unknown); + /** Hide the field entirely when this returns false. Re-evaluated on every render. */ + visibleWhen?: (ctx: FieldContext) => boolean; + /** Display the field but disable editing. */ + readOnly?: boolean; +} + +export interface TextField extends FieldBase { + kind: 'text'; + maxLength?: number; + pattern?: RegExp; + placeholder?: string; +} +export interface TextareaField extends FieldBase { + kind: 'textarea'; + rows?: number; + maxLength?: number; + placeholder?: string; +} +export interface MarkdownField extends FieldBase { + kind: 'markdown'; + rows?: number; + maxLength?: number; +} +export interface SelectField extends FieldBase { + kind: 'select'; + options: Option[]; +} +export interface SelectAsyncField extends FieldBase { + kind: 'select-async'; + loadOptions: () => Promise | Option[]; +} +export interface MultiSelectAsyncField extends FieldBase { + kind: 'multi-select-async'; + loadOptions: () => Promise | Option[]; +} +/** Series of free-text inputs — used for pulse options (2–4 entries). */ +export interface MultiTextField extends FieldBase { + kind: 'multi-text'; + minItems?: number; + maxItems?: number; + placeholderEach?: string; +} +export interface DateField extends FieldBase { + kind: 'date'; +} +export interface DatetimeField extends FieldBase { + kind: 'datetime'; +} +export interface NumberField extends FieldBase { + kind: 'number'; + min?: number; + max?: number; + step?: number; +} +/** Display-only — never edited; renders the value verbatim or via render(). */ +export interface ReadonlyField extends FieldBase { + kind: 'readonly'; + render?: (value: unknown, item: Record | null) => string; +} + +export type Field = + | TextField + | TextareaField + | MarkdownField + | SelectField + | SelectAsyncField + | MultiSelectAsyncField + | MultiTextField + | DateField + | DatetimeField + | NumberField + | ReadonlyField; + +// ── Columns ───────────────────────────────────────────────────────────────── +interface ColumnBase { + key: string; + label: string; + /** CSS grid-template-columns track (e.g. '1.7fr', '120px'). */ + width?: string; + /** Primary column gets the larger title styling. At most one per columns array. */ + primary?: boolean; + /** When set, the list can be sorted by this column. */ + sortable?: boolean; +} + +export interface TextColumn extends ColumnBase { + kind?: 'text'; + /** Override the default {item[key]} rendering. */ + render?: (item: T) => { title: string; subtitle?: string }; +} +export interface PillColumn extends ColumnBase { + kind: 'pill'; + pillVariants: PillVariants; + /** Override which value to look up in pillVariants (default = item[key]). */ + valueOf?: (item: T) => string; +} +export interface RelativeDateColumn extends ColumnBase { + kind: 'relative-date'; + /** Shown when the value is null/undefined. */ + emptyFallback?: string; + valueOf?: (item: T) => string | null | undefined; +} +export interface NumberColumn extends ColumnBase { + kind: 'number'; + valueOf?: (item: T) => number | null | undefined; +} +/** Compact list of pills — for focus_tags, audience, etc. */ +export interface TagListColumn extends ColumnBase { + kind: 'tag-list'; + valueOf: (item: T) => string[]; +} + +export type Column = + | TextColumn + | PillColumn + | RelativeDateColumn + | NumberColumn + | TagListColumn; + +// ── Filters ───────────────────────────────────────────────────────────────── +export interface Filter { + key: string; + label: string; + predicate: (item: T) => boolean; + isDefault?: boolean; +} + +// ── Search ────────────────────────────────────────────────────────────────── +export interface SearchConfig { + placeholder: string; + /** Object keys to search; coerced to string and matched case-insensitively. */ + fields: (keyof T & string)[]; +} + +// ── Sort ──────────────────────────────────────────────────────────────────── +export interface SortConfig { + key: string; + direction: 'asc' | 'desc'; +} + +// ── List view config ──────────────────────────────────────────────────────── +export interface ListConfig { + queryFn: () => T[] | Promise; + /** Default column set. */ + columns: Column[]; + /** + * Override columns when a specific filter is active. The key matches a + * Filter.key. Used by the Users resource (council vs pilots) to swap + * member_number/focus_tags for role/last_seen_at. + */ + columnsByFilter?: Record[]>; + filters?: Filter[]; + search?: SearchConfig; + defaultSort?: SortConfig; + pageSize?: number; +} + +// ── Embedded sub-forms (the Pulse fieldset inside dispatch edit) ──────────── +export interface FormEmbed { + /** Unique key inside the parent form. */ + key: string; + title: string; + /** + * Discriminator the panel uses to pick a renderer component. + * Keep this small — new embed kinds are explicit additions, not generic. + */ + component: 'pulse-sub-form'; + visibleWhen?: (ctx: FieldContext) => boolean; +} + +// ── Form config ───────────────────────────────────────────────────────────── +export interface FormConfig { + fields: Field[]; + /** Optional embedded sub-form sections (e.g. pulse inside dispatch). */ + embeds?: FormEmbed[]; +} + +// ── Op context — passed to CRUD ops and actions ───────────────────────────── +export interface OpContext { + user: { id: number; role: string }; +} + +// ── CRUD operations ───────────────────────────────────────────────────────── +export interface ResourceOps { + /** Returns the new item's id. */ + create?: (data: Record, ctx: OpContext) => number | Promise; + update?: (id: number, data: Record, ctx: OpContext) => void | Promise; + delete?: (id: number, ctx: OpContext) => void | Promise; + getById?: (id: number) => T | null | Promise; +} + +// ── Action results — surfaced inside the edit panel ───────────────────────── +/** No additional UI — just close the panel with a toast. */ +export interface ActionResultToast { + kind: 'toast'; +} +/** Render the generated invite link in the panel with a Copy button. */ +export interface ActionResultInviteLink { + kind: 'invite-link'; + url: string; +} +export type ActionResult = ActionResultToast | ActionResultInviteLink; + +// ── Actions (publish, archive, approve, etc.) ─────────────────────────────── +export interface ResourceAction { + key: string; + label: string; + /** Hide the action when this returns false for the current item. */ + visibleWhen?: (item: T) => boolean; + /** Confirm dialog text. If omitted, no confirmation is shown. */ + confirmText?: string; + /** Destructive actions render in terracotta. */ + destructive?: boolean; + handler: (id: number, ctx: OpContext) => ActionResult | void | Promise; +} + +// ── Notification count (sidebar badge in terracotta if > 0) ───────────────── +export interface NotifyCount { + /** Return the count of items needing attention (pending requests, stale drafts, etc.). */ + count: (items: T[]) => number; +} + +// ── Resource ──────────────────────────────────────────────────────────────── +export interface Resource> { + /** URL slug — /admin/. */ + key: string; + label: string; + pluralLabel: string; + singularLabel: string; + /** Matches a ResourceGroup.key in the registry. */ + groupKey: string; + /** Optional one-line subtitle under the page title. */ + description?: string; + /** Returns the member-facing URL for an item (for the "View on portal" link). */ + publicRoutePattern?: (item: T) => string | null; + list: ListConfig; + /** null marks the resource as read-only (no edit panel, no "+ New" button). */ + form: FormConfig | null; + ops: ResourceOps; + actions?: ResourceAction[]; + notifyCount?: NotifyCount; +} + +// ── Resource groups (sidebar sections) ────────────────────────────────────── +export interface ResourceGroup { + key: string; + label: string; + // Each resource carries its own item type. Erase the generic at the + // registration boundary so different resources can coexist in one array. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resources: Resource[]; +} diff --git a/src/admin/validate.ts b/src/admin/validate.ts new file mode 100644 index 0000000..e1caee4 --- /dev/null +++ b/src/admin/validate.ts @@ -0,0 +1,116 @@ +/* --------------------------------------------------------------------------- + * Form validation derived from a Resource's field definitions. + * + * Returns a map of field.key → error message. Empty object = valid. + * Server-side authoritative; the client may show hints, but every POST + * must call this before writing to the DB. + * ------------------------------------------------------------------------- */ + +import type { Field, FieldContext, Resource } from './resource-types'; + +export type ValidationErrors = Record; + +export interface ValidateArgs { + resource: Resource; + data: Record; + /** Existing item being edited, or null on create. */ + item: Record | null; + actingUserId: number; +} + +export function validateForResource(args: ValidateArgs): ValidationErrors { + const { resource, data, item, actingUserId } = args; + const errors: ValidationErrors = {}; + + if (!resource.form) return errors; + + const ctx: FieldContext = { formValues: data, item, actingUserId }; + + for (const field of resource.form.fields) { + if (field.readOnly) continue; + if (field.visibleWhen && !field.visibleWhen(ctx)) continue; + + const value = data[field.key]; + const error = validateField(field, value); + if (error) errors[field.key] = error; + } + + return errors; +} + +function validateField(field: Field, value: unknown): string | null { + const isEmpty = + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0); + + if (field.required && isEmpty) { + return `${field.label} is required`; + } + if (isEmpty) return null; + + switch (field.kind) { + case 'text': + case 'textarea': + case 'markdown': { + if (typeof value !== 'string') return `${field.label} must be text`; + if ('maxLength' in field && field.maxLength && value.length > field.maxLength) { + return `${field.label} must be ${field.maxLength} characters or fewer`; + } + if (field.kind === 'text' && field.pattern && !field.pattern.test(value)) { + return `${field.label} is not in the expected format`; + } + return null; + } + + case 'select': + case 'select-async': { + // Empty already handled. Anything else is accepted at the validate layer; + // option membership is enforced by the route handler against a fresh option list. + return null; + } + + case 'multi-select-async': { + if (!Array.isArray(value)) return `${field.label} must be a list`; + return null; + } + + case 'multi-text': { + if (!Array.isArray(value)) return `${field.label} must be a list`; + const filled = value.filter( + (v) => typeof v === 'string' && v.trim() !== '', + ); + if (field.minItems !== undefined && filled.length < field.minItems) { + return `${field.label} requires at least ${field.minItems} entries`; + } + if (field.maxItems !== undefined && filled.length > field.maxItems) { + return `${field.label} allows at most ${field.maxItems} entries`; + } + return null; + } + + case 'number': { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) return `${field.label} must be a number`; + if (field.min !== undefined && n < field.min) { + return `${field.label} must be at least ${field.min}`; + } + if (field.max !== undefined && n > field.max) { + return `${field.label} must be no more than ${field.max}`; + } + return null; + } + + case 'date': + case 'datetime': { + if (typeof value !== 'string') return `${field.label} must be a date`; + const t = Date.parse(value); + if (Number.isNaN(t)) return `${field.label} is not a valid date`; + return null; + } + + case 'readonly': + return null; + } +} diff --git a/tests/admin-validate.test.ts b/tests/admin-validate.test.ts new file mode 100644 index 0000000..fc2140e --- /dev/null +++ b/tests/admin-validate.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { validateForResource } from '../src/admin/validate'; +import type { Resource } from '../src/admin/resource-types'; + +// Minimal resource fixture covering the field kinds the validator handles. +const resource: Resource = { + key: 'fixtures', + label: 'Fixture', + pluralLabel: 'Fixtures', + singularLabel: 'Fixture', + groupKey: 'system', + list: { queryFn: () => [], columns: [] }, + ops: {}, + form: { + fields: [ + { key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 5 }, + { key: 'kind', label: 'Kind', kind: 'select', options: [{ value: 'a', label: 'A' }] }, + { + key: 'options', + label: 'Options', + kind: 'multi-text', + minItems: 2, + maxItems: 4, + }, + { key: 'count', label: 'Count', kind: 'number', min: 0, max: 10 }, + { + key: 'extras', + label: 'Extras', + kind: 'text', + // Only visible when kind === 'a' + visibleWhen: (ctx) => ctx.formValues.kind === 'a', + required: true, + }, + ], + }, +}; + +const baseArgs = { resource, item: null, actingUserId: 1 }; + +describe('validateForResource', () => { + it('reports required fields that are empty', () => { + const errors = validateForResource({ ...baseArgs, data: {} }); + expect(errors.title).toBe('Title is required'); + }); + + it('reports maxLength violations on text fields', () => { + const errors = validateForResource({ ...baseArgs, data: { title: 'too long' } }); + expect(errors.title).toMatch(/5 characters or fewer/); + }); + + it('skips fields hidden by visibleWhen', () => { + // kind = 'b' hides the 'extras' required field — no error expected + const errors = validateForResource({ + ...baseArgs, + data: { title: 'ok', kind: 'b' }, + }); + expect(errors.extras).toBeUndefined(); + }); + + it('enforces visibleWhen-revealed required fields', () => { + const errors = validateForResource({ + ...baseArgs, + data: { title: 'ok', kind: 'a' }, + }); + expect(errors.extras).toBe('Extras is required'); + }); + + it('enforces multi-text min/max items', () => { + const tooFew = validateForResource({ + ...baseArgs, + data: { title: 'ok', options: ['only-one'] }, + }); + expect(tooFew.options).toMatch(/at least 2/); + + const tooMany = validateForResource({ + ...baseArgs, + data: { title: 'ok', options: ['1', '2', '3', '4', '5'] }, + }); + expect(tooMany.options).toMatch(/at most 4/); + }); + + it('enforces number min/max', () => { + const tooHigh = validateForResource({ + ...baseArgs, + data: { title: 'ok', count: 99 }, + }); + expect(tooHigh.count).toMatch(/no more than 10/); + }); + + it('returns no errors when every field is valid', () => { + const errors = validateForResource({ + ...baseArgs, + data: { + title: 'ok', + kind: 'a', + options: ['x', 'y'], + count: 5, + extras: 'fine', + }, + }); + expect(errors).toEqual({}); + }); + + it('treats form: null as always valid', () => { + const readOnly: Resource = { ...resource, form: null }; + const errors = validateForResource({ + ...baseArgs, + resource: readOnly, + data: {}, + }); + expect(errors).toEqual({}); + }); +});