Files
summercms/admin/src/components/form/registry.ts
Jakub Zych 126ca5b8ed feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the
  document language from meta.locale and renders plural messages with
  Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME
- Create and record routes; mapWinterUrl maps recordUrl and redirects
  onto the controller's list, create and record routes only
- List rows open their record; FormView loads the form schema and the
  record, shows context-allowed fields in the span grid, saves values
  keyed by field name and toasts the resolved saved message
- A 422 puts each message under its field (aria-invalid,
  aria-describedby), shows the plural banner, focuses the first invalid
  field in schema order and clears a field's error on change
- The D-05 registry maps text, textarea, number and dropdown; any other
  type renders the unsupported-field box with the type in DM Mono
- The admin OpenAPI document declares the write request bodies
  (AdminRecord, AdminIDsRequest) and the list filter query as a
  deepObject, so the typed client can send them
- New backend::lang form.load_failed key; boardwalk/dist rebuilt
2026-09-27 16:43:47 +02:00

85 lines
2.8 KiB
TypeScript

// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
// seam into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
}
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>()
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type)
}
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}