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:
parent
103bfa2f0c
commit
ea056fff7b
3 changed files with 524 additions and 0 deletions
295
src/admin/resource-types.ts
Normal file
295
src/admin/resource-types.ts
Normal 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 (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<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
116
src/admin/validate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
tests/admin-validate.test.ts
Normal file
113
tests/admin-validate.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue