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

@@ -26,6 +26,14 @@ export function onUnauthorized(handler: UnauthorizedHandler | null): void {
unauthorizedHandler = handler
}
/**
* Reports a session that could not be recovered, for requests that do not
* go through the typed client (the upload XMLHttpRequest).
*/
export function reportUnauthorized(): void {
unauthorizedHandler?.()
}
/** Registers a listener for successful refreshes (the new access lifetime). */
export function onRefreshed(handler: RefreshedHandler | null): void {
refreshedHandler = handler

312
admin/src/api/files.ts Normal file
View File

@@ -0,0 +1,312 @@
// File routes of a fileupload field (D-02, D-09, D-10). FileuploadField talks
// to one FileRoutes object and never knows whether it edits a record (the
// record file routes, keyed by X-Session-Key) or a related record inside a
// relation child modal (the child file routes, which also carry
// X-Child-Session-Key). Session keys travel in headers only, never in a URL.
//
// Uploads use XMLHttpRequest, the only browser API that reports upload
// progress. The request carries the same X-Requested-With header and
// same-origin cookie as the typed client, and a 401 refreshes the session once
// and sends the file again. Protected thumbnails and downloads are fetched as
// blobs through the typed client, so the SPA can show them from object URLs.
import { REQUESTED_WITH, api, refreshSession, reportUnauthorized } from './client'
import type { AdminFileCaptionRequest, ControllerParams, ErrorBody, FileItem, FileMutationResult } from './types'
import { runtime } from '../app/runtime'
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../app/sessionKey'
/** The outcome of one JSON file call: data on success, else the error body. */
export interface FileCallResult<T> {
data: T | null
status: number
error: ErrorBody | null
}
/** Why an upload did not store a file. */
export type UploadFailure = 'invalid' | 'too_large' | 'network' | 'failed' | 'aborted'
export type UploadResult =
| { ok: true; item: FileItem }
| { ok: false; reason: UploadFailure; status: number; message: string }
/** A running upload: its result and a way to cancel it. */
export interface UploadHandle {
promise: Promise<UploadResult>
abort(): void
}
export type UploadProgress = (loaded: number, total: number) => void
/** Everything a fileupload control does with its files. */
export interface FileRoutes {
list(): Promise<FileCallResult<FileItem[]>>
upload(file: File, onProgress?: UploadProgress): UploadHandle
update(file: number, body: AdminFileCaptionRequest): Promise<FileCallResult<FileItem>>
remove(file: number): Promise<FileCallResult<FileMutationResult>>
reorder(ids: number[]): Promise<FileCallResult<FileItem[]>>
/** A protected file's bytes, or null when they could not be loaded. */
download(file: number): Promise<Blob | null>
/** A protected image's preview thumbnail, or null. */
thumb(file: number): Promise<Blob | null>
}
interface TypedResult<T> {
data?: { data: T }
error?: { error: ErrorBody }
response: Response
}
async function settle<T>(call: () => Promise<TypedResult<T>>): Promise<FileCallResult<T>> {
try {
const result = await call()
return {
data: result.data ? result.data.data : null,
status: result.response.status,
error: result.error?.error ?? null,
}
} catch {
return { data: null, status: 0, error: null }
}
}
async function blob(call: () => Promise<{ data?: Blob; response: Response }>): Promise<Blob | null> {
try {
const result = await call()
return result.response.ok && result.data instanceof Blob ? result.data : null
} catch {
return null
}
}
function segment(value: string | number): string {
return encodeURIComponent(String(value))
}
function controllerUrl(source: ControllerParams): string {
return `${runtime.api}/${segment(source.vendor)}/${segment(source.plugin)}/${segment(source.controller)}`
}
function parse(text: string): unknown {
try {
return text === '' ? null : JSON.parse(text)
} catch {
return null
}
}
/** The first message of a 422: the field's detail, else the error message. */
export function errorMessage(error: ErrorBody | null | undefined): string {
if (!error) {
return ''
}
for (const value of Object.values(error.details ?? {})) {
const first = Array.isArray(value) ? value.find((item) => typeof item === 'string' && item !== '') : value
if (typeof first === 'string' && first !== '') {
return first
}
}
return error.message ?? ''
}
function classify(status: number, body: unknown): UploadResult {
const envelope = body as { data?: FileItem; error?: ErrorBody } | null
if ((status === 200 || status === 201) && envelope?.data) {
return { ok: true, item: envelope.data }
}
const message = errorMessage(envelope?.error)
if (status === 413) {
return { ok: false, reason: 'too_large', status, message }
}
if (status === 422) {
return { ok: false, reason: 'invalid', status, message }
}
if (status === 0) {
return { ok: false, reason: 'network', status, message }
}
return { ok: false, reason: 'failed', status, message }
}
type Sent = { status: number; body: unknown } | 'network' | 'aborted'
/**
* Posts one file as the multipart part `file_data` with progress events.
* The XMLHttpRequest sends X-Requested-With and the given session headers
* with the same-origin cookie; a 401 refreshes the session once and retries.
*/
export function uploadWithProgress(
url: string,
file: File,
headers: Record<string, string>,
onProgress?: UploadProgress,
): UploadHandle {
let current: XMLHttpRequest | null = null
let aborted = false
const send = () =>
new Promise<Sent>((resolve) => {
const request = new XMLHttpRequest()
current = request
request.open('POST', url)
request.setRequestHeader('X-Requested-With', REQUESTED_WITH)
request.setRequestHeader('Accept', 'application/json')
for (const [name, value] of Object.entries(headers)) {
request.setRequestHeader(name, value)
}
request.upload.onprogress = (event: ProgressEvent) => {
if (event.lengthComputable) {
onProgress?.(event.loaded, event.total)
}
}
request.onload = () => resolve({ status: request.status, body: parse(request.responseText) })
request.onerror = () => resolve('network')
request.ontimeout = () => resolve('network')
request.onabort = () => resolve('aborted')
const form = new FormData()
form.append('file_data', file, file.name)
request.send(form)
})
const promise = (async (): Promise<UploadResult> => {
let sent = await send()
if (typeof sent === 'object' && sent.status === 401 && !aborted) {
if (await refreshSession()) {
sent = aborted ? 'aborted' : await send()
}
if (typeof sent === 'object' && sent.status === 401) {
reportUnauthorized()
}
}
if (sent === 'aborted' || aborted) {
return { ok: false, reason: 'aborted', status: 0, message: '' }
}
if (sent === 'network') {
return { ok: false, reason: 'network', status: 0, message: '' }
}
return classify(sent.status, sent.body)
})()
return {
promise,
abort() {
aborted = true
current?.abort()
},
}
}
/**
* The record file routes of one field. recordId is 0 for the record being
* created; the server then holds every file against sessionKey.
*/
export function parentFileRoutes(
source: ControllerParams,
recordId: number,
field: string,
sessionKey: string,
): FileRoutes {
const path = { ...source, id: recordId, field }
const header = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }
const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}`
return {
list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
body,
}),
),
remove: (file) =>
settle(() =>
api.DELETE('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
}),
),
reorder: (ids) =>
settle(() =>
api.POST('/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder', {
params: { path, header },
body: { ids },
}),
),
download: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
thumb: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
}
}
/**
* The file routes of a related record's field inside a relation child modal.
* childId is 0 for the child being created. Both keys travel: the record
* form's key scopes an unsaved parent, the child modal's key holds the files.
*/
export function childFileRoutes(
source: ControllerParams,
recordId: number,
relation: string,
childId: number,
field: string,
sessionKey: string,
childKey: string,
): FileRoutes {
const path = { ...source, id: recordId, name: relation, child: childId, field }
const header = { [SESSION_HEADER]: sessionKey, [CHILD_SESSION_HEADER]: childKey } as {
'X-Session-Key': string
'X-Child-Session-Key': string
}
const url = `${controllerUrl(source)}/${segment(recordId)}/relations/${segment(relation)}/records/${segment(childId)}/files/${segment(field)}`
return {
list: () =>
settle(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}', {
params: { path, header },
}),
),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
body,
}),
),
remove: (file) =>
settle(() =>
api.DELETE('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
}),
),
reorder: (ids) =>
settle(() =>
api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder', {
params: { path, header },
body: { ids },
}),
),
download: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
thumb: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
}
}

View File

@@ -51,6 +51,16 @@ export type MessageForms = Schemas['cabana.MessageForms']
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
export type LangBundle = Schemas['cabana.LangBundle']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One file of a fileupload field; `pending` while it waits for the save. */
export type FileItem = Schemas['cabana.FileItem']
/** Result of a file removal: how many files were removed. */
export type FileMutationResult = Schemas['cabana.FileMutationResult']
/** Body of a caption save: title and description, omitted keys unchanged. */
export type AdminFileCaptionRequest = Schemas['cabana.AdminFileCaptionRequest']
/** Body of a relation link: the ids and, for one id, its pivot values. */
export type AdminRelationLinkRequest = Schemas['cabana.AdminRelationLinkRequest']
/** A fileupload field's preview thumbnail mode. */
export type ThumbOptions = Schemas['cabana.ThumbOptions']
/** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.AdminRecord']
export type ErrorBody = Schemas['cabana.ErrorBody']

View File

@@ -0,0 +1,28 @@
// Form session keys (D-02). A record form makes one key when it mounts and a
// relation child modal makes its own when it opens. Uploads, file removals,
// deferred relation calls and the final save carry the key in a header, so
// the server can hold the work against it and commit it with the save. A key
// never appears in a URL: it would end up in logs and the browser history.
/** Header of the record form's key. */
export const SESSION_HEADER = 'X-Session-Key'
/** Header of a relation child modal's own key. */
export const CHILD_SESSION_HEADER = 'X-Child-Session-Key'
/** Random bytes per key: 256 bits, above D-02's 128-bit floor. */
const KEY_BYTES = 32
/**
* A new key: 32 bytes from crypto.getRandomValues encoded as unpadded
* base64url, 43 characters of A-Z a-z 0-9 _ - (the server's key pattern).
*/
export function newSessionKey(): string {
const bytes = new Uint8Array(KEY_BYTES)
globalThis.crypto.getRandomValues(bytes)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { CircleAlert, FileText, X } from '@lucide/vue'
import { errorMessage, type FileRoutes } from '../../../api/files'
import type { FileItem } from '../../../api/types'
import { t } from '../../../app/i18n'
import { showToast } from '../../../state/useToasts'
import ConfirmDialog from '../../ui/ConfirmDialog.vue'
import { useConfirm } from '../../ui/confirm'
import { controlClass } from '../control'
// Attachment details (UI-SPEC section 3, caption modal; D-09). Title and
// description of one file are saved at once through the caption route (A7),
// not with the form, so saving never marks the form dirty. A 422 stays in
// the dialog under its inputs; any other failure keeps the input and shows
// a danger toast. Closing with changes asks first.
const props = defineProps<{
open: boolean
file: FileItem | null
routes: FileRoutes | null
/** Preview thumbnail source, or null for the file icon. */
preview: string | null
idPrefix: string
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
saved: [file: FileItem]
closed: []
}>()
const title = ref('')
const description = ref('')
const errors = ref<Record<string, string[]>>({})
const busy = ref(false)
const confirm = useConfirm()
/** 422 details as string lists (formState is not imported: registry cycle). */
function detailMessages(details: Record<string, unknown> | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const [name, value] of Object.entries(details ?? {})) {
const list = (Array.isArray(value) ? value : [value]).filter(
(item): item is string => typeof item === 'string' && item !== '',
)
if (list.length > 0) {
out[name] = list
}
}
return out
}
const titleId = computed(() => `${props.idPrefix}-caption-title`)
const descriptionId = computed(() => `${props.idPrefix}-caption-description`)
const dirty = computed(
() => !!props.file && (title.value !== props.file.title || description.value !== props.file.description),
)
watch(
() => props.open,
(open) => {
if (open && props.file) {
title.value = props.file.title ?? ''
description.value = props.file.description ?? ''
errors.value = {}
busy.value = false
}
},
{ immediate: true },
)
async function setOpen(open: boolean): Promise<void> {
if (open) {
emit('update:open', true)
return
}
if (busy.value) {
return
}
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
}
}
emit('update:open', false)
}
async function onSubmit(): Promise<void> {
if (busy.value || !props.file || !props.routes) {
return
}
busy.value = true
try {
const result = await props.routes.update(props.file.id, { title: title.value, description: description.value })
if (result.data) {
errors.value = {}
emit('saved', result.data)
emit('update:open', false)
showToast(t('backend::lang.fileupload.details_saved'))
return
}
if (result.status === 422) {
errors.value = detailMessages(result.error?.details)
if (Object.keys(errors.value).length === 0) {
errors.value = { title: [errorMessage(result.error) || t('backend::lang.form.error_generic')] }
}
return
}
showToast(result.error?.message || t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
function onCloseAutoFocus(event: Event): void {
event.preventDefault()
emit('closed')
}
</script>
<template>
<DialogRoot :open="open" @update:open="setOpen">
<DialogPortal>
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
<DialogContent
data-caption-modal
data-dialog-content
aria-modal="true"
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
@close-auto-focus="onCloseAutoFocus"
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ t('backend::lang.fileupload.attachment') }}</DialogTitle>
<DialogDescription class="text-muted">{{ t('backend::lang.fileupload.help') }}</DialogDescription>
</div>
<DialogClose
data-action="close"
:aria-label="t('backend::lang.form.close')"
:disabled="busy"
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text"
>
<X :size="16" aria-hidden="true" />
</DialogClose>
</div>
<div v-if="file" class="flex items-center gap-4">
<img v-if="preview" :src="preview" alt="" class="size-16 shrink-0 rounded-inner object-cover" />
<span v-else class="flex size-16 shrink-0 items-center justify-center rounded-inner bg-subtle" aria-hidden="true">
<FileText :size="16" class="text-muted" />
</span>
<span class="min-w-0 truncate text-[13px] text-muted" :title="file.file_name">{{ file.file_name }}</span>
</div>
<form class="flex flex-col gap-4" novalidate @submit.prevent="onSubmit">
<div class="flex flex-col gap-1.5">
<label :for="titleId" class="font-semibold">{{ t('backend::lang.fileupload.title_label') }}</label>
<input
:id="titleId"
v-model="title"
type="text"
data-caption-title
:disabled="busy"
:aria-invalid="errors.title ? 'true' : undefined"
:aria-describedby="errors.title ? `${titleId}-error` : undefined"
:class="controlClass(!!errors.title)"
class="h-input"
/>
<p v-if="errors.title" :id="`${titleId}-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ errors.title.join(' ') }}</span>
</p>
</div>
<div class="flex flex-col gap-1.5">
<label :for="descriptionId" class="font-semibold">{{ t('backend::lang.fileupload.description_label') }}</label>
<textarea
:id="descriptionId"
v-model="description"
rows="3"
data-caption-description
:disabled="busy"
:aria-invalid="errors.description ? 'true' : undefined"
:aria-describedby="errors.description ? `${descriptionId}-error` : undefined"
:class="controlClass(!!errors.description)"
class="min-h-input resize-y py-2.5"
/>
<p
v-if="errors.description"
:id="`${descriptionId}-error`"
class="flex items-center gap-1.5 text-[13px] text-danger"
>
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ errors.description.join(' ') }}</span>
</p>
</div>
<div class="flex gap-2.5">
<button
type="button"
data-action="cancel"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
@click="setOpen(false)"
>
{{ t('backend::lang.form.cancel') }}
</button>
<button
type="submit"
data-action="save-caption"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.fileupload.details_submit') }}
</button>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
<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"
/>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
// clears that field's errors exactly like typing into it. A form without a
// provider (settings pages) gets the defaults of the injecting control.
import type { InjectionKey, Ref } from 'vue'
import type { FileRoutes } from '../../api/files'
import type { AdminRecord } from '../../api/types'
/** Read-only view of the form's current values. */
@@ -21,6 +22,34 @@ export const FORM_LOCALE: InjectionKey<Readonly<Ref<string>>> = Symbol('summer.f
*/
export const FORM_ASSETS: InjectionKey<() => Promise<string[]>> = Symbol('summer.form.assets')
/**
* The form's deferred-binding session (D-02, D-03). FormView makes one per
* mount; a relation child modal provides its own for the fields inside it.
* Uploads, removals and deferred relation calls go out with `key` (and
* `childKey` in a child modal) and are committed by the form's next save.
*/
export interface FormSession {
/** The record form's session key (X-Session-Key). */
key: string
/** The child modal's own key (X-Child-Session-Key); absent on a record form. */
childKey?: string
/** Id of the record the files belong to; 0 while it is not saved yet. */
recordId: number
/** The file routes of one fileupload field of this form. */
routes(field: string): FileRoutes
/**
* Records a change held against the session (an upload, a removal, a
* cancelled upload, a deferred relation change): the form becomes dirty.
*/
markDirty(): void
/** How many such changes the form holds; 0 after a successful save. */
pendingChanges: Readonly<Ref<number>>
/** Goes up after every successful save, so fields can reload their files. */
revision: Readonly<Ref<number>>
}
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'

View File

@@ -6,9 +6,13 @@
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
// element (D-04, D-09) and a partial renders a server template (D-17). They
// hold no form value either, but they render on create and update.
// Phase 12.2 adds the fileupload control: it edits its files through the file
// routes against the form's session key (D-02, D-03), so it holds no form
// value either and renders on create and update.
import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import RelationManager from '../relation/RelationManager.vue'
@@ -39,6 +43,7 @@ const renderers = new Map<string, Component>([
[RELATION_MANAGER, RelationManager],
['widget', WidgetField],
['partial', PartialField],
['fileupload', FileuploadField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
@@ -51,13 +56,13 @@ const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
const recordBound = new Set<string>([RELATION_MANAGER])
/** Types that hold no form value: never part of the save body (D-09). */
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial'])
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
/**
* Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input.
*/
const groupLabelledTypes = new Set<string>(['widget', 'partial'])
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField

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
}

View File

@@ -0,0 +1,68 @@
{
"data": {
"name": "Gadget",
"fields": [
{
"name": "name",
"label": "Name",
"type": "text",
"required": true
},
{
"name": "photos",
"label": "Photos",
"type": "fileupload",
"mode": "image",
"multiple": true,
"maxFiles": 3,
"fileTypes": [
"jpg",
"png"
],
"maxFilesize": 2,
"thumbOptions": {
"mode": "crop"
},
"useCaption": true
}
],
"messages": {
"create": {
"other": "New gadget"
},
"update": {
"other": "Editing gadget"
},
"saved": {
"other": "Gadget „:name” saved"
},
"deleteConfirm": {
"other": "Delete this gadget?"
},
"deleted": {
"other": "Gadget deleted"
}
},
"meta": {
"locale": "en"
},
"assets": {
"scripts": [],
"styles": []
},
"redirects": {
"create": {
"redirect": "acme/demo/gadgets/update/:id",
"redirectClose": "acme/demo/gadgets"
},
"update": {
"redirect": "acme/demo/gadgets",
"redirectClose": "acme/demo/gadgets"
},
"default": "acme/demo/gadgets"
}
},
"meta": {
"locale": "en"
}
}

View File

@@ -10,6 +10,7 @@ import settingsJson from './settings.json'
import extensionFormSchemaJson from './extension.form-schema.json'
import extensionListSchemaJson from './extension.list-schema.json'
import extensionPartialJson from './extension.partial.json'
import deferredFormSchemaJson from './deferred.form-schema.json'
import formSchemaJson from './widgets.form-schema.json'
import listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json'
@@ -30,6 +31,8 @@ export const settingsFixture: {
values: S['cabana.Envelope-cabana_SettingsResult']
} = settingsJson
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
/** A gadget form with deferred-binding fields: fileupload (Phase 12.2). */
export const deferredFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = deferredFormSchemaJson
/** A form with a plugin widget and its controller script (Phase 10.1). */
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
/** A list with a header partial and a controller stylesheet (Phase 10.1). */

View File

@@ -39,7 +39,7 @@ describe('field renderer registry (D-05)', () => {
expect(rendererFor(type)).toBe(component)
})
it.each(['colorpicker', 'fileupload', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
it.each(['colorpicker', 'codeeditor', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
expect(rendererFor(type)).toBe(UnsupportedField)
expect(isRegistered(type)).toBe(false)
})

View File

@@ -0,0 +1,212 @@
// Deferred binding end to end in the SPA (Phase 12.2, D-02, D-03): a new
// record's form makes one session key, uploads go to record id 0 with that
// key in a header, the form turns dirty, and the create save sends the same
// key so the server can attach the files. The upload XMLHttpRequest is
// replaced by a fake the test answers by hand.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import { deferredFormSchemaFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, type Route } from '../helpers'
const BASE = `${API}/acme/demo/gadgets`
const KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/
class FakeXHR {
static sent: FakeXHR[] = []
method = ''
url = ''
headers: Record<string, string> = {}
body: FormData | null = null
status = 0
responseText = ''
upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }
onload: (() => void) | null = null
onerror: (() => void) | null = null
onabort: (() => void) | null = null
ontimeout: (() => void) | null = null
open(method: string, url: string): void {
this.method = method
this.url = url
}
setRequestHeader(name: string, value: string): void {
this.headers[name] = value
}
send(body: FormData): void {
this.body = body
FakeXHR.sent.push(this)
}
abort(): void {
this.onabort?.()
}
progress(loaded: number, total: number): void {
this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent)
}
respond(status: number, body: unknown): void {
this.status = status
this.responseText = JSON.stringify(body)
this.onload?.()
}
}
function fileItem(id: number, name: string, pending: boolean) {
return {
id,
file_name: name,
file_size: 1_200_000,
content_type: 'image/png',
title: '',
description: '',
sort_order: id,
pending,
created_at: '2026-10-02T10:00:00Z',
url: `/storage/app/uploads/public/${name}`,
thumb_url: `/storage/app/uploads/public/thumb_${name}`,
}
}
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
[`GET ${BASE}/0/files/photos`]: { body: { data: [], meta: {} } },
[`POST ${BASE}`]: { status: 201, body: created },
[`GET ${BASE}/5`]: { body: created },
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
...overrides,
}
}
async function choose(wrapper: VueWrapper, files: File[]): Promise<void> {
const input = wrapper.find('[data-fileupload-input]')
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
await input.trigger('change')
await flushPromises()
}
function png(name: string, size = 1024): File {
return new File([new Uint8Array(size)], name, { type: 'image/png' })
}
beforeEach(() => {
clearUser()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')
FakeXHR.sent = []
vi.stubGlobal('XMLHttpRequest', FakeXHR)
})
afterEach(() => {
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
describe('fileupload on a new record (tracer)', () => {
it('uploads to record 0 with the form key, turns the form dirty and saves with the same key', async () => {
const { wrapper, calls, router } = await mountApp('/acme/demo/gadgets/create', routes())
// The file list of the unsaved record is read with the form's key.
const [list] = requestsTo(calls, 'GET', `${BASE}/0/files/photos`)
expect(list).toBeDefined()
const key = list!.headers.get(SESSION_HEADER) ?? ''
expect(key).toMatch(KEY_PATTERN)
expect(new URL(list!.url).search).toBe('')
expect(wrapper.find('[data-fileupload-dropzone]').text()).toContain('backend::lang.fileupload.default_prompt_many')
await choose(wrapper, [png('box.png')])
expect(FakeXHR.sent).toHaveLength(1)
const upload = FakeXHR.sent[0]!
expect(upload.method).toBe('POST')
expect(new URL(upload.url, 'http://local').pathname).toBe(`${BASE}/0/files/photos`)
expect(upload.url).not.toContain('?')
expect(upload.headers[SESSION_HEADER]).toBe(key)
expect(upload.headers['X-Requested-With']).toBe('XMLHttpRequest')
expect((upload.body!.get('file_data') as File).name).toBe('box.png')
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
upload.progress(50, 100)
await flushPromises()
expect(wrapper.find('[data-state="uploading"]').text()).toContain('backend::lang.fileupload.uploading')
upload.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
await flushPromises()
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
// On a create form every file is pending, so no Unsaved chip.
expect(wrapper.find('[data-unsaved-chip]').exists()).toBe(false)
// The upload alone makes the form dirty: leaving asks first.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
const confirm = document.body.querySelector('[data-confirm-dialog]')
expect(confirm).not.toBeNull()
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
await flushPromises()
expect(router.currentRoute.value.name).toBe('create')
await wrapper.find('#field-name').setValue('Box')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [save] = requestsTo(calls, 'POST', BASE)
expect(save).toBeDefined()
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
expect(await save!.clone().json()).toEqual({ name: 'Box' })
// The create follows its redirect to the saved record, with a fresh key.
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets/5')
const [reload] = requestsTo(calls, 'GET', `${BASE}/5/files/photos`)
expect(reload).toBeDefined()
const next = reload!.headers.get(SESSION_HEADER) ?? ''
expect(next).toMatch(KEY_PATTERN)
expect(next).not.toBe(key)
await flushPromises()
expect(wrapper.find('[data-file-item]').text()).toContain('box.png')
})
it('refuses a wrong type and an oversized file before sending and drops files past maxFiles', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets/create', routes())
await choose(wrapper, [
new File(['x'], 'notes.txt', { type: 'text/plain' }),
png('huge.png', 3 * (1 << 20)),
png('a.png'),
png('b.png'),
png('c.png'),
png('d.png'),
])
const failed = wrapper.findAll('[data-state="failed"]')
expect(failed).toHaveLength(2)
expect(failed[0]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.wrong_type')
expect(failed[1]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.too_large')
expect(wrapper.find('[data-fileupload-too-many]').attributes('role')).toBe('alert')
// One upload at a time: a.png is sent, b.png and c.png wait, d.png is dropped.
expect(FakeXHR.sent).toHaveLength(1)
expect((FakeXHR.sent[0]!.body!.get('file_data') as File).name).toBe('a.png')
expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2)
FakeXHR.sent[0]!.respond(422, {
error: { code: 'validation_failed', message: 'The given data was invalid.', details: { photos: ['The photos must be an image.'] } },
})
await flushPromises()
expect(wrapper.findAll('[data-state="failed"]')[2]!.find('[data-file-error]').text()).toContain(
'The photos must be an image.',
)
expect(FakeXHR.sent).toHaveLength(2)
expect((FakeXHR.sent[1]!.body!.get('file_data') as File).name).toBe('b.png')
})
})