Files
summercms/admin/src/views/FormView.vue
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

210 lines
7.2 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import {
contextAllows,
editablePayload,
fieldErrors,
focusField,
initialValues,
snapshot,
type FormMode,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
// keyed by route path, so one instance serves one record or one create.
const route = useRoute()
const router = useRouter()
const ID_PREFIX = 'field'
const path = {
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}
const controllerId = controllerIdFromParams(path)
const listPath = controllerPath(controllerId) ?? '/'
const mode: FormMode = route.name === 'create' ? 'create' : 'update'
const recordId = mode === 'update' ? Number(route.params.id) : null
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({})
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
const saved = ref('')
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
const fieldNames = computed(() => fields.value.map((field) => field.name))
/** The record's display name: the first text field's value, in schema order. */
const recordName = computed(() => {
const first = schema.value?.fields.find((field) => field.type === 'text')
const value = first ? values.value[first.name] : undefined
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
})
const title = computed(() => {
if (mode === 'update' && recordName.value !== '') {
return recordName.value
}
const forms = mode === 'create' ? schema.value?.messages.create : schema.value?.messages.update
return message(forms, undefined, { name: recordName.value })
})
const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
const dirty = computed(() => snapshot(editablePayload(fields.value, values.value)) !== saved.value)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
values.value = { ...record.data }
labels.value = record.meta.labels ?? {}
}
saved.value = snapshot(editablePayload(fields.value, values.value))
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
const params = { path }
const [schemaResult, recordResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/form', { params }),
recordId !== null
? api.GET('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } } })
: Promise.resolve(null),
])
schema.value = schemaResult.data?.data ?? null
failed.value = !schemaResult.data || (recordResult !== null && !recordResult.data)
if (schema.value && mode === 'create') {
values.value = initialValues(fields.value)
}
adopt(recordResult?.data)
loading.value = false
}
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
errors.value = next
}
}
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)
await nextTick()
const first = fields.value.find((field) => errors.value[field.name])
if (first) {
focusField(ID_PREFIX, first.name)
}
}
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
const redirects = schema.value?.redirects
const url = (redirects?.[mode]?.[kind] || redirects?.default) ?? ''
return mapWinterUrl(url, controllerId, typeof id === 'number' || typeof id === 'string' ? id : null)
}
/** Saves the record; returns the saved envelope, or null after an error. */
async function save(): Promise<RecordEnvelope | null> {
if (saving.value || !schema.value) {
return null
}
saving.value = true
try {
const body = editablePayload(fields.value, values.value)
const result =
recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path }, body })
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } }, body })
if (result.data) {
errors.value = {}
adopt(result.data)
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
return result.data
}
if (result.response.status === 422) {
await showErrors(result.error?.error)
} else {
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
}
return null
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
return null
} finally {
saving.value = false
}
}
async function onSave(): Promise<void> {
const record = await save()
if (record && mode === 'create') {
await router.push(redirectTarget('redirect', record.data.id))
}
}
defineExpose({ dirty })
void load()
</script>
<template>
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
<header class="flex items-center gap-4">
<div class="flex min-w-0 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
</div>
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
{{ t('backend::lang.form.load_failed') }}
</p>
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<form
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="fields"
:values="values"
:errors="errors"
:labels="labels"
:source="path"
:id-prefix="ID_PREFIX"
@update="update"
/>
</form>
</template>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<Button variant="ghost" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</footer>
</section>
</template>