feat(admin): add Resource type definitions + form validator

Establishes the load-bearing type surface for the Backstage rebuild:

- src/admin/resource-types.ts — discriminated unions for Field, Column,
  Filter, Action, plus the top-level Resource and ResourceGroup. Strict
  per the maintainability bar: a config object missing a required key
  fails TypeScript.
- src/admin/validate.ts — validateForResource() derives validation
  from the field definitions (required, maxLength, multi-text min/max,
  number bounds, date parse, visibleWhen-aware).
- tests/admin-validate.test.ts — 8 cases locking the validator API:
  required, maxLength, visibleWhen skip & reveal, multi-text bounds,
  number bounds, all-valid, form-null short-circuit.

No consumers yet. Next commit pulls these into admin.css and the
shared layout components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 15:56:42 +02:00
parent 103bfa2f0c
commit ea056fff7b
3 changed files with 524 additions and 0 deletions

295
src/admin/resource-types.ts Normal file
View file

@ -0,0 +1,295 @@
/* ---------------------------------------------------------------------------
* Resource type definitions for the Backstage admin surface.
*
* Every admin-managed entity is declared as a single Resource<T> 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<V = string | number> {
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<string, PillVariant>;
// ── Field context — passed to visibleWhen / defaultValue resolvers ──────────
export interface FieldContext {
/** Current form values keyed by field.key. */
formValues: Record<string, unknown>;
/** The item being edited, or null on create. */
item: Record<string, unknown> | 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[]> | Option[];
}
export interface MultiSelectAsyncField extends FieldBase {
kind: 'multi-select-async';
loadOptions: () => Promise<Option[]> | Option[];
}
/** Series of free-text inputs — used for pulse options (24 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<string, unknown> | 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<T> extends ColumnBase {
kind?: 'text';
/** Override the default <td>{item[key]}</td> rendering. */
render?: (item: T) => { title: string; subtitle?: string };
}
export interface PillColumn<T> extends ColumnBase {
kind: 'pill';
pillVariants: PillVariants;
/** Override which value to look up in pillVariants (default = item[key]). */
valueOf?: (item: T) => string;
}
export interface RelativeDateColumn<T> extends ColumnBase {
kind: 'relative-date';
/** Shown when the value is null/undefined. */
emptyFallback?: string;
valueOf?: (item: T) => string | null | undefined;
}
export interface NumberColumn<T> extends ColumnBase {
kind: 'number';
valueOf?: (item: T) => number | null | undefined;
}
/** Compact list of pills — for focus_tags, audience, etc. */
export interface TagListColumn<T> extends ColumnBase {
kind: 'tag-list';
valueOf: (item: T) => string[];
}
export type Column<T> =
| TextColumn<T>
| PillColumn<T>
| RelativeDateColumn<T>
| NumberColumn<T>
| TagListColumn<T>;
// ── Filters ─────────────────────────────────────────────────────────────────
export interface Filter<T> {
key: string;
label: string;
predicate: (item: T) => boolean;
isDefault?: boolean;
}
// ── Search ──────────────────────────────────────────────────────────────────
export interface SearchConfig<T> {
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<T> {
queryFn: () => T[] | Promise<T[]>;
/** Default column set. */
columns: Column<T>[];
/**
* 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<string, Column<T>[]>;
filters?: Filter<T>[];
search?: SearchConfig<T>;
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<T> {
/** Returns the new item's id. */
create?: (data: Record<string, unknown>, ctx: OpContext) => number | Promise<number>;
update?: (id: number, data: Record<string, unknown>, ctx: OpContext) => void | Promise<void>;
delete?: (id: number, ctx: OpContext) => void | Promise<void>;
getById?: (id: number) => T | null | Promise<T | null>;
}
// ── 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<T> {
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<ActionResult | void>;
}
// ── Notification count (sidebar badge in terracotta if > 0) ─────────────────
export interface NotifyCount<T> {
/** Return the count of items needing attention (pending requests, stale drafts, etc.). */
count: (items: T[]) => number;
}
// ── Resource ────────────────────────────────────────────────────────────────
export interface Resource<T = Record<string, unknown>> {
/** URL slug — /admin/<key>. */
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<T>;
/** null marks the resource as read-only (no edit panel, no "+ New" button). */
form: FormConfig | null;
ops: ResourceOps<T>;
actions?: ResourceAction<T>[];
notifyCount?: NotifyCount<T>;
}
// ── 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<any>[];
}

116
src/admin/validate.ts Normal file
View file

@ -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<string, string>;
export interface ValidateArgs {
resource: Resource;
data: Record<string, unknown>;
/** Existing item being edited, or null on create. */
item: Record<string, unknown> | 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;
}
}

View file

@ -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({});
});
});