Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them. Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
16 KiB
Vue
447 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, provide, readonly, ref, 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_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
|
|
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
|
import {
|
|
DEFAULT_TAB,
|
|
contextAllows,
|
|
editablePayload,
|
|
fieldErrors,
|
|
focusField,
|
|
initialValues,
|
|
panelDomId,
|
|
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: 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 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 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(
|
|
() =>
|
|
!loading.value &&
|
|
(pendingChanges.value > 0 ||
|
|
activeUploads.value > 0 ||
|
|
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) {
|
|
// 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)
|
|
}
|
|
adopt(recordResult?.data)
|
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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_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
|
|
try {
|
|
const body = editablePayload(fields.value, values.value)
|
|
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 {
|
|
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
|
|
}
|
|
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), '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,
|
|
})
|
|
}
|
|
|
|
onBeforeRouteLeave(guard)
|
|
onBeforeRouteUpdate(guard)
|
|
|
|
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
|
if (dirty.value) {
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
}
|
|
|
|
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
|
|
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
|
|
|
|
// 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"
|
|
:to="listPath"
|
|
:aria-label="t('backend::lang.form.return_to_list')"
|
|
class="size-10! px-0!"
|
|
>
|
|
<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" />
|
|
<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" :to="listPath">{{ 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"
|
|
/>
|
|
</section>
|
|
</template>
|