feat(12.2-04): add the fileupload field with deferred uploads on the form session key

- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers
- api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry
- FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews
- FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update
- fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
This commit is contained in:
Jakub Zych
2026-10-02 19:22:22 +02:00
parent 1ebfe691a2
commit ea33296799
22 changed files with 2338 additions and 15 deletions

View File

@@ -1,17 +1,19 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watchEffect } from 'vue'
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_VALUES } from '../components/form/formContext'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
import { needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
@@ -67,6 +69,16 @@ const confirm = useConfirm()
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)
// 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).
@@ -132,7 +144,11 @@ const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
const dirty = computed(() => !loading.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value)
const dirty = computed(
() =>
!loading.value &&
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
@@ -182,6 +198,16 @@ 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),
})
/** 422: messages under fields, the first invalid field (schema order) focused. */
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
@@ -214,11 +240,17 @@ async function save(): Promise<RecordEnvelope | null> {
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 })
? 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
}