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

@@ -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'