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:
Jakub Zych
2026-09-27 16:43:47 +02:00
parent 453b8ae786
commit 126ca5b8ed
41 changed files with 1930 additions and 37 deletions

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rendererFor, type FieldControlProps } from './registry'
// Picks the control for field.type from the registry (D-05) and forwards the
// shared control props and the value update.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const control = computed(() => rendererFor(props.field.type))
</script>
<template>
<component
:is="control"
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { t, tc } from '../../app/i18n'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server
// message is lost.
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>()
const count = computed(() => Object.keys(props.errors).length)
const orphans = computed(() =>
Object.entries(props.errors)
.filter(([name]) => !props.fieldNames.includes(name))
.flatMap(([, messages]) => messages),
)
</script>
<template>
<div
v-if="count > 0"
role="alert"
data-error-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
<div class="flex flex-col gap-1">
<p>
<strong>{{ t('backend::lang.form.error_title') }}</strong>
{{ tc('backend::lang.form.error_fields', count) }}
</p>
<p v-for="(text, index) in orphans" :key="index" class="text-[13px]">{{ text }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
const props = defineProps<{
field: FormField
modelValue: unknown
errors?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
/** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
.filter(Boolean)
.join(' '),
)
</script>
<template>
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<FieldRenderer
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue'
// Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
idPrefix?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string {
switch (span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
return 'min-[600px]:col-start-2'
case 'auto':
case 'row':
return ''
default:
return 'col-span-full'
}
}
</script>
<template>
<div class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field.span)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:labels="labels?.[field.name]"
:source="source"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
const EMPTY = 'empty'
const PLACEHOLDER = 'placeholder'
const options = computed(() => props.field.options ?? [])
function same(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) {
return a === b || (a == null && b == null)
}
return String(a) === String(b)
}
// The <select> works on indexes so option values keep their JSON type
// (string, number or boolean) when they are sent back.
const selected = computed(() => {
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
if (index >= 0) {
return String(index)
}
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
return EMPTY
}
return PLACEHOLDER
})
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
function onChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value
if (value === EMPTY) {
emit('update:modelValue', null)
return
}
const option = options.value[Number(value)]
if (option) {
emit('update:modelValue', option.value)
}
}
</script>
<template>
<div class="relative">
<select
:id="controlId"
:name="field.name"
:value="selected"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
class="h-input appearance-none pr-10"
@change="onChange"
>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
{{ option.label }}
</option>
</select>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
// An empty input clears the value (null); anything else is sent as a number.
function onInput(event: Event): void {
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === '') {
emit('update:modelValue', null)
return
}
const value = Number(raw)
emit('update:modelValue', Number.isFinite(value) ? value : null)
}
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="number"
inputmode="decimal"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input tabular-nums"
@input="onInput"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// Winter textarea sizes mapped to rows; the design's Opis uses 4 (small).
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 5)
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<textarea
:id="controlId"
v-bind="attrs"
:name="field.name"
:rows="rows"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="min-h-input resize-y py-2.5"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Puzzle } from '@lucide/vue'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
// D-05 fallback for any field type without a renderer. The translated
// sentence is split around the type so the type renders in DM Mono, as text.
const props = defineProps<FieldControlProps>()
const MARK = '\u0000'
const parts = computed(() => {
const [before = '', after = ''] = t('backend::lang.form.unsupported_field', { type: MARK }).split(MARK)
return { before, after }
})
const hasType = computed(() => t('backend::lang.form.unsupported_field', { type: MARK }).includes(MARK))
</script>
<template>
<div
:id="controlId"
data-unsupported-field
:aria-describedby="describedBy || undefined"
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
>
<Puzzle :size="16" aria-hidden="true" />
<span v-if="hasType"
>{{ parts.before }}<code class="font-mono text-text">{{ props.field.type }}</code>{{ parts.after }}</span
>
<span v-else>{{ t('backend::lang.form.unsupported_field') }} <code class="font-mono text-text">{{ props.field.type }}</code></span>
</div>
</template>

View 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
}

View File

@@ -0,0 +1,84 @@
// 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(' ')
}