project-bifrost-platform/src/admin/validate.ts
Jonathan Hvid ea056fff7b 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>
2026-05-12 15:56:42 +02:00

116 lines
3.7 KiB
TypeScript

/* ---------------------------------------------------------------------------
* 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;
}
}