Files
summercms/admin/src/views/FormView.vue
Jakub Zych a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00

549 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, readonly, ref, watch, watchEffect } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import { parentFileRoutes } from '../api/files'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { currentLocale, message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { SESSION_HEADER, newSessionKey } from '../app/sessionKey'
import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
ML_LOCALE_CHANGE,
changedLocales,
contextAllows,
editablePayload,
fieldErrors,
focusField,
initialValues,
isMLFieldType,
mergeMLValue,
panelDomId,
presetUpdates,
recordDisplayName,
schemaEnabledLocales,
snapshot,
tabDomId,
tabOf,
type FormMode,
type TabItem,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
// 4). The view is keyed by route path, so one instance serves one record or
// one create. Tabs group fields by `tab`; a sticky footer holds delete,
// cancel, save-and-close and save; a dirty form confirms before leaving.
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: Exclude<FormMode, 'preview'> = 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[]>>({})
// The text of a save the server refused with 403 (UI-SPEC S6). It is a
// banner, not a toast: it stays readable until the next save attempt.
const forbidden = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
const saved = ref('')
const activeTab = ref(DEFAULT_TAB)
const confirm = useConfirm()
// Set right before a navigation the form itself starts after a save or a
// delete, so the dirty guard does not ask.
let leaving = false
// The controller's plugin scripts: settled when every one loaded or failed.
let assetsReady: Promise<string[]> = Promise.resolve([])
// One deferred-binding session per mount (D-02): uploads, file removals and
// deferred relation calls are held against it and committed by the save,
// which sends the same key. The view is keyed by path, so a create that
// navigates to its record gets a fresh key.
const sessionKey = newSessionKey()
const sessionHeader = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }
// Changes held against the session; any of them makes the form dirty.
const pendingChanges = ref(0)
// Bumped after each successful save, so file fields reload their lists.
const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
// A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
// A deferrable one (Phase 12.2, D-03) stays: it works on owner id 0 with the
// form's session key and the save commits its changes.
const fields = computed(() =>
(schema.value?.fields ?? []).filter(
(field) =>
contextAllows(field, mode) &&
(mode === 'update' || !needsRecord(field.type) || (field.type === RELATION_MANAGER && !!field.deferrable)),
),
)
const fieldNames = computed(() => fields.value.map((field) => field.name))
const tabs = computed<TabItem[]>(() => {
if (!fields.value.some((field) => field.tab)) {
return []
}
const out: TabItem[] = []
for (const field of fields.value) {
const key = tabOf(field)
let tab = out.find((item) => item.key === key)
if (!tab) {
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
if (errors.value[field.name]) {
tab.errors += 1
}
}
return out
})
const activeIndex = computed(() => Math.max(
tabs.value.findIndex((tab) => tab.key === activeTab.value),
0,
))
const panelFields = computed(() => {
if (tabs.value.length === 0) {
return fields.value
}
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
return fields.value.filter((field) => tabOf(field) === key)
})
/** The record's display name: the first text-like field's value, in schema order. */
const recordName = computed(() =>
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
)
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 }) : '',
)
// An update form of a record that has a preview screen is entered from it,
// so back and Cancel return there (UI-SPEC S3); a delete still goes to the list.
const backToPreview = computed(() => mode === 'update' && !!schema.value?.preview)
const backPath = computed(() => (backToPreview.value ? `${listPath}/${recordId}/preview` : listPath))
const dirty = computed(
() =>
!loading.value &&
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value, mode)) !== saved.value),
)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
const locales = schemaEnabledLocales(schema.value?.meta)
const next: AdminRecord = { ...record.data }
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLFieldType(field.type)) {
next[field.name] = mergeMLValue(record.data[field.name], locales)
}
}
values.value = next
labels.value = record.meta.labels ?? {}
}
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
}
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) {
// Plugin files load in the background; only widgets wait for them (D-14).
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
}
if (schema.value && mode === 'create') {
values.value = initialValues(fields.value, schemaEnabledLocales(schema.value.meta))
}
adopt(recordResult?.data)
mlLocale.value = firstMLLocale()
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
loading.value = false
}
// Preset fields (fields.yaml `preset`, UI-SPEC S7): on create, a text or
// mltext field follows its text or mltext source until the administrator
// edits it by hand; that stops it for as long as the form is open. An mltext
// pair follows per locale and a hand edit of one locale stops only that
// locale; a mixed pair follows the active ML locale (presetUpdates). On
// update nothing follows.
const edited = new Set<string>()
// The locales of each ML field the administrator edited by hand.
const editedLocales = new Map<string, Set<string>>()
// The locale every ML control shows (any ML control broadcasts the choice).
const mlLocale = ref('en')
function firstMLLocale(): string {
return schemaEnabledLocales(schema.value?.meta)[0] ?? 'en'
}
function onMLLocale(event: Event): void {
const locale = (event as CustomEvent<unknown>).detail
if (typeof locale === 'string' && locale !== '') {
mlLocale.value = locale
}
}
// FormGrid remounts the new panel's ML controls on the first enabled locale.
watch(activeTab, () => {
mlLocale.value = firstMLLocale()
})
function isEdited(target: string, locale?: string): boolean {
return locale === undefined ? edited.has(target) : (editedLocales.get(target)?.has(locale) ?? false)
}
function update(name: string, value: unknown): void {
const previous = values.value[name]
const next = { ...values.value, [name]: value }
edited.add(name)
const type = fields.value.find((field) => field.name === name)?.type
if (isMLFieldType(type)) {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()
editedLocales.set(name, locales)
}
for (const locale of changedLocales(previous, value)) {
locales.add(locale)
}
}
if (mode === 'create') {
Object.assign(next, presetUpdates(fields.value, name, previous, value, values.value, mlLocale.value, isEdited))
}
values.value = next
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
errors.value = next
}
}
// Extension controls (widgets) read the values and locale and write action
// results back through update, so a fill marks the form dirty and clears
// that field's errors like an edit (D-07, D-08).
provide(FORM_VALUES, computed(() => values.value))
provide(FORM_PATCH, update)
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
provide(FORM_ASSETS, () => assetsReady)
provide(FORM_SESSION, {
key: sessionKey,
recordId: recordId ?? 0,
routes: (field: string) => parentFileRoutes(path, recordId ?? 0, field, sessionKey),
markDirty: () => {
pendingChanges.value++
},
pendingChanges: readonly(pendingChanges),
revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
})
/** 422: messages under fields, the first invalid field (schema order) focused. */
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)
const invalid = fields.value.filter((field) => errors.value[field.name])
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (!first) {
return
}
if (tabs.value.length > 0) {
activeTab.value = tabOf(first)
}
await nextTick()
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 (busy.value || !schema.value || activeUploads.value > 0) {
return null
}
busy.value = true
forbidden.value = null
try {
const body = editablePayload(fields.value, values.value, mode)
const result =
recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body })
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', {
params: { path: { ...path, id: recordId }, header: sessionHeader },
body,
})
if (result.data) {
errors.value = {}
adopt(result.data)
// The save committed everything held against the session.
pendingChanges.value = 0
revision.value++
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
return result.data
}
if (result.response.status === 422) {
await showErrors(result.error?.error)
} else if (result.response.status === 403) {
// Nothing was saved: every entered value and the dirty state stay.
// Fields the refusal names are marked and the first is focused.
forbidden.value = result.error?.error.message || t('backend::lang.form.forbidden')
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 {
busy.value = false
}
}
async function go(to: string): Promise<void> {
leaving = true
try {
await router.push(to)
} finally {
leaving = false
}
}
/** Zapisz: stays on the form; after a create it follows create.redirect. */
async function onSave(): Promise<void> {
const record = await save()
if (record && mode === 'create') {
await go(redirectTarget('redirect', record.data.id))
}
}
/** Zapisz i zamknij: saves, then follows redirectClose (the toast follows). */
async function onSaveAndClose(): Promise<void> {
const record = await save()
if (record) {
await go(redirectTarget('redirectClose', record.data.id))
}
}
async function onDelete(): Promise<void> {
if (recordId === null || busy.value || !schema.value) {
return
}
const ok = await confirm.ask({
message: message(schema.value.messages.deleteConfirm, undefined, { name: recordName.value }),
confirmLabel: t('backend::lang.form.delete'),
danger: true,
})
if (!ok) {
return
}
busy.value = true
try {
const result = await api.DELETE('/{vendor}/{plugin}/{controller}/{id}', {
params: { path: { ...path, id: recordId } },
})
if (result.data) {
showToast(message(schema.value.messages.deleted, undefined, { name: recordName.value }))
await go(listPath)
return
}
const fallback = result.response.status === 403 ? 'backend::lang.list.action_forbidden' : 'backend::lang.form.error_generic'
showToast(result.error?.error.message || t(fallback), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
/** Leaving a dirty form (Anuluj, back, any route change) asks first. */
async function guard(): Promise<boolean> {
if (leaving || !dirty.value) {
return true
}
return confirm.ask({
message: t('backend::lang.form.unsaved_confirm'),
confirmLabel: t('backend::lang.form.discard'),
danger: true,
})
}
/** Anuluj / back: ask here so the confirm is not tied to a pending RouterLink. */
async function onLeave(): Promise<void> {
if (dirty.value) {
const ok = await confirm.ask({
message: t('backend::lang.form.unsaved_confirm'),
confirmLabel: t('backend::lang.form.discard'),
danger: true,
})
if (!ok) {
return
}
}
await go(backPath.value)
}
onBeforeRouteLeave(guard)
onBeforeRouteUpdate(guard)
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (dirty.value) {
event.preventDefault()
event.returnValue = ''
}
}
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
window.addEventListener(ML_LOCALE_CHANGE, onMLLocale)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener(ML_LOCALE_CHANGE, onMLLocale)
})
// The header's last breadcrumb is this record's title (design: Top header).
watchEffect(() => setRecordCrumb(loading.value ? '' : title.value))
onBeforeUnmount(clearRecordCrumb)
void load()
</script>
<template>
<section class="flex w-full flex-col gap-5 pb-24">
<header class="flex flex-wrap items-center gap-4">
<Button
variant="outline"
data-action="back"
:aria-label="t(backToPreview ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')"
class="size-10! px-0!"
@click="onLeave"
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-1 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>
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
</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" :forbidden="forbidden" />
<form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="panelFields"
:values="values"
:errors="errors"
:labels="labels"
:source="path"
:record-id="recordId"
:id-prefix="ID_PREFIX"
@update="update"
/>
</form>
</template>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<Button
v-if="mode === 'update'"
variant="danger"
data-action="delete"
:icon="Trash2"
:disabled="busy || loading || !schema"
@click="onDelete"
>
{{ t('backend::lang.form.delete') }}
</Button>
<div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" @click="onLeave">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
{{ t('backend::lang.form.save_and_close') }}
</Button>
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSave">
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</div>
</footer>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
@update:open="(open) => { if (!open) confirm.cancel() }"
/>
</section>
</template>