- 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
210 lines
7.2 KiB
Vue
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>
|