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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user