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
This commit is contained in:
83
admin/src/components/form/formState.ts
Normal file
83
admin/src/components/form/formState.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Helpers shared by the record form and the settings form: context
|
||||
// filtering, the save payload, 422 detail mapping and focus.
|
||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||
import { isRegistered } from './registry'
|
||||
|
||||
export type FormMode = 'create' | 'update'
|
||||
|
||||
/** Winter `context:` semantics: no context shows the field everywhere. */
|
||||
export function contextAllows(field: FormField, mode: FormMode): boolean {
|
||||
const context = field.context
|
||||
if (context === undefined || context === null) {
|
||||
return true
|
||||
}
|
||||
const values = Array.isArray(context) ? context : [context]
|
||||
return values.length === 0 || values.includes(mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* The save body: values keyed by field name for every editable field that
|
||||
* has a value. Read-only fields and types without a renderer are never sent.
|
||||
*/
|
||||
export function editablePayload(fields: FormField[], values: AdminRecord): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.readOnly || !isRegistered(field.type)) {
|
||||
continue
|
||||
}
|
||||
const value = values[field.name]
|
||||
if (value !== undefined) {
|
||||
out[field.name] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Initial values of a new record: schema defaults, toggles off, no ids. */
|
||||
export function initialValues(fields: FormField[]): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.default !== undefined) {
|
||||
out[field.name] = field.default
|
||||
} else if (field.type === 'switch' || field.type === 'checkbox') {
|
||||
out[field.name] = false
|
||||
} else if (field.type === 'relation' && field.multiple) {
|
||||
out[field.name] = []
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** D-10 422 details (field -> messages) as a string-list map. */
|
||||
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [name, value] of Object.entries(details ?? {})) {
|
||||
const messages = Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string' && item !== '')
|
||||
: typeof value === 'string' && value !== ''
|
||||
? [value]
|
||||
: []
|
||||
if (messages.length > 0) {
|
||||
out[name] = messages
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Stable serialization for dirty checks. */
|
||||
export function snapshot(record: AdminRecord): string {
|
||||
return JSON.stringify(Object.keys(record).sort().map((key) => [key, record[key]]))
|
||||
}
|
||||
|
||||
/** Focuses a field's control (FormField ids are `${prefix}-${name}`). */
|
||||
export function focusField(prefix: string, name: string): boolean {
|
||||
const element = document.getElementById(`${prefix}-${name}`)
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
if (!element.matches('input, select, textarea, button, [tabindex]')) {
|
||||
element.setAttribute('tabindex', '-1')
|
||||
}
|
||||
element.focus()
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user