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:
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
phase: "13"
|
||||||
|
slug: "p-ytarium-api-wishlist-notifications-csv-credentials-public"
|
||||||
|
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||||
|
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: "2026-10-02"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 13 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Go `testing` (+ testify, Go fuzzing), testcontainers Postgres |
|
||||||
|
| **Config file** | none — `fonoteka.go/parity/parity_test.go` TestMain starts Postgres |
|
||||||
|
| **Quick run command** | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short -count=1` |
|
||||||
|
| **Full suite command** | `go vet ./... && go test ./... -count=1 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` |
|
||||||
|
| **Parity command** | `go -C ../fonoteka.go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` |
|
||||||
|
| **Phase gate** | `../fonoteka.go/scripts/check-phase13.sh --self-test && ../fonoteka.go/scripts/check-phase13.sh --all` |
|
||||||
|
| **Estimated runtime** | ~180 seconds (full suite with testcontainers) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run the quick run command plus `go vet` in the touched repo
|
||||||
|
- **After every plan wave:** Run the full suite command, the parity command and the corpus check
|
||||||
|
- **Before `/gsd-verify-work`:** `scripts/check-phase13.sh --all` must be green
|
||||||
|
- **Max feedback latency:** 180 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
Filled by the planner per task; the requirement → test map lives in `13-RESEARCH.md` § Validation Architecture.
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 13-01-01 | 01 | 1 | (framework) | T-13-23 | Overlapping constrained routes dispatch to the right handler | unit | `go test ./modules/surf -run TestOverlappingConstrainedRoutes -count=1` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `surf` constraint-aware overlap dispatch, plus a test (blocks every wishlist route)
|
||||||
|
- [ ] `conga` unregistered-kind insert while a worker runs, plus a test
|
||||||
|
- [ ] `lagoon` `prohibited` rule; tide Content-Disposition date and notification publication masks
|
||||||
|
- [ ] `php_parity.sh` `QUEUE_CONNECTION` override; `capture-rules.yaml` `share:wishlist` capture
|
||||||
|
- [ ] `fonoteka_reset.php` + `seedFonotekaCase` states: `wishlist`, `csv`, `credentials`, `empty`, `invite-for-register`
|
||||||
|
- [ ] `scripts/check-phase13.sh` (copy of the check-phase12 structure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Re-recording PHP fixtures against the isolated PHP instance | API-03..API-07 | Needs the local PHP stack running | Run `php_parity.sh` recordings per D-12 with the database queue override |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 180s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -26,6 +26,14 @@ export function onUnauthorized(handler: UnauthorizedHandler | null): void {
|
|||||||
unauthorizedHandler = handler
|
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). */
|
/** Registers a listener for successful refreshes (the new access lifetime). */
|
||||||
export function onRefreshed(handler: RefreshedHandler | null): void {
|
export function onRefreshed(handler: RefreshedHandler | null): void {
|
||||||
refreshedHandler = handler
|
refreshedHandler = handler
|
||||||
|
|||||||
312
admin/src/api/files.ts
Normal file
312
admin/src/api/files.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,16 @@ export type MessageForms = Schemas['cabana.MessageForms']
|
|||||||
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
|
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
|
||||||
export type LangBundle = Schemas['cabana.LangBundle']
|
export type LangBundle = Schemas['cabana.LangBundle']
|
||||||
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
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. */
|
/** One record: a string-keyed map read through its list or form schema. */
|
||||||
export type AdminRecord = Schemas['cabana.AdminRecord']
|
export type AdminRecord = Schemas['cabana.AdminRecord']
|
||||||
export type ErrorBody = Schemas['cabana.ErrorBody']
|
export type ErrorBody = Schemas['cabana.ErrorBody']
|
||||||
|
|||||||
28
admin/src/app/sessionKey.ts
Normal file
28
admin/src/app/sessionKey.ts
Normal 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(/=+$/, '')
|
||||||
|
}
|
||||||
241
admin/src/components/form/fields/FileCaptionModal.vue
Normal file
241
admin/src/components/form/fields/FileCaptionModal.vue
Normal 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>
|
||||||
1213
admin/src/components/form/fields/FileuploadField.vue
Normal file
1213
admin/src/components/form/fields/FileuploadField.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
// clears that field's errors exactly like typing into it. A form without a
|
// clears that field's errors exactly like typing into it. A form without a
|
||||||
// provider (settings pages) gets the defaults of the injecting control.
|
// provider (settings pages) gets the defaults of the injecting control.
|
||||||
import type { InjectionKey, Ref } from 'vue'
|
import type { InjectionKey, Ref } from 'vue'
|
||||||
|
import type { FileRoutes } from '../../api/files'
|
||||||
import type { AdminRecord } from '../../api/types'
|
import type { AdminRecord } from '../../api/types'
|
||||||
|
|
||||||
/** Read-only view of the form's current values. */
|
/** 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')
|
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). */
|
/** Event a widget element dispatches to run its action (bubbles, composed). */
|
||||||
export const WIDGET_EVENT = 'summer-action'
|
export const WIDGET_EVENT = 'summer-action'
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
|
// 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
|
// 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.
|
// 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 type { Component } from 'vue'
|
||||||
import CheckboxField from './fields/CheckboxField.vue'
|
import CheckboxField from './fields/CheckboxField.vue'
|
||||||
import DropdownField from './fields/DropdownField.vue'
|
import DropdownField from './fields/DropdownField.vue'
|
||||||
|
import FileuploadField from './fields/FileuploadField.vue'
|
||||||
import NumberField from './fields/NumberField.vue'
|
import NumberField from './fields/NumberField.vue'
|
||||||
import PartialField from './fields/PartialField.vue'
|
import PartialField from './fields/PartialField.vue'
|
||||||
import RelationManager from '../relation/RelationManager.vue'
|
import RelationManager from '../relation/RelationManager.vue'
|
||||||
@@ -39,6 +43,7 @@ const renderers = new Map<string, Component>([
|
|||||||
[RELATION_MANAGER, RelationManager],
|
[RELATION_MANAGER, RelationManager],
|
||||||
['widget', WidgetField],
|
['widget', WidgetField],
|
||||||
['partial', PartialField],
|
['partial', PartialField],
|
||||||
|
['fileupload', FileuploadField],
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
/** 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])
|
const recordBound = new Set<string>([RELATION_MANAGER])
|
||||||
|
|
||||||
/** Types that hold no form value: never part of the save body (D-09). */
|
/** 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
|
* 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.
|
* 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 {
|
export function rendererFor(type: string): Component {
|
||||||
return renderers.get(type) ?? UnsupportedField
|
return renderers.get(type) ?? UnsupportedField
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<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 { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
import { parentFileRoutes } from '../api/files'
|
||||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
||||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
import { currentLocale, message, t } from '../app/i18n'
|
import { currentLocale, message, t } from '../app/i18n'
|
||||||
import { loadControllerAssets } from '../app/pluginAssets'
|
import { loadControllerAssets } from '../app/pluginAssets'
|
||||||
|
import { SESSION_HEADER, newSessionKey } from '../app/sessionKey'
|
||||||
import { mapWinterUrl } from '../app/winterUrl'
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||||
import FormGrid from '../components/form/FormGrid.vue'
|
import FormGrid from '../components/form/FormGrid.vue'
|
||||||
import FormTabs from '../components/form/FormTabs.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 { needsRecord } from '../components/form/registry'
|
||||||
import {
|
import {
|
||||||
DEFAULT_TAB,
|
DEFAULT_TAB,
|
||||||
@@ -67,6 +69,16 @@ const confirm = useConfirm()
|
|||||||
let leaving = false
|
let leaving = false
|
||||||
// The controller's plugin scripts: settled when every one loaded or failed.
|
// The controller's plugin scripts: settled when every one loaded or failed.
|
||||||
let assetsReady: Promise<string[]> = Promise.resolve([])
|
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
|
// 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).
|
// 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 }) : '',
|
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 {
|
function adopt(record: RecordEnvelope | undefined): void {
|
||||||
if (record) {
|
if (record) {
|
||||||
@@ -182,6 +198,16 @@ provide(FORM_VALUES, computed(() => values.value))
|
|||||||
provide(FORM_PATCH, update)
|
provide(FORM_PATCH, update)
|
||||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
||||||
provide(FORM_ASSETS, () => assetsReady)
|
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. */
|
/** 422: messages under fields, the first invalid field (schema order) focused. */
|
||||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
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 body = editablePayload(fields.value, values.value)
|
||||||
const result =
|
const result =
|
||||||
recordId === null
|
recordId === null
|
||||||
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path }, body })
|
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body })
|
||||||
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } }, body })
|
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', {
|
||||||
|
params: { path: { ...path, id: recordId }, header: sessionHeader },
|
||||||
|
body,
|
||||||
|
})
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
adopt(result.data)
|
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 }))
|
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|||||||
68
admin/tests/fixtures/deferred.form-schema.json
vendored
Normal file
68
admin/tests/fixtures/deferred.form-schema.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -10,6 +10,7 @@ import settingsJson from './settings.json'
|
|||||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||||
import extensionListSchemaJson from './extension.list-schema.json'
|
import extensionListSchemaJson from './extension.list-schema.json'
|
||||||
import extensionPartialJson from './extension.partial.json'
|
import extensionPartialJson from './extension.partial.json'
|
||||||
|
import deferredFormSchemaJson from './deferred.form-schema.json'
|
||||||
import formSchemaJson from './widgets.form-schema.json'
|
import formSchemaJson from './widgets.form-schema.json'
|
||||||
import listJson from './widgets.list.json'
|
import listJson from './widgets.list.json'
|
||||||
import listSchemaJson from './widgets.list-schema.json'
|
import listSchemaJson from './widgets.list-schema.json'
|
||||||
@@ -30,6 +31,8 @@ export const settingsFixture: {
|
|||||||
values: S['cabana.Envelope-cabana_SettingsResult']
|
values: S['cabana.Envelope-cabana_SettingsResult']
|
||||||
} = settingsJson
|
} = settingsJson
|
||||||
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
|
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). */
|
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('field renderer registry (D-05)', () => {
|
|||||||
expect(rendererFor(type)).toBe(component)
|
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(rendererFor(type)).toBe(UnsupportedField)
|
||||||
expect(isRegistered(type)).toBe(false)
|
expect(isRegistered(type)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
212
admin/tests/smoke/deferred.smoke.test.ts
Normal file
212
admin/tests/smoke/deferred.smoke.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,8 @@ A plugin route under the admin prefix also fails the start-up: the SPA and the a
|
|||||||
|
|
||||||
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
|
The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
|
||||||
|
|
||||||
|
Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.
|
||||||
|
|
||||||
## Types from OpenAPI
|
## Types from OpenAPI
|
||||||
|
|
||||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:
|
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:
|
||||||
|
|||||||
9
modules/boardwalk/dist/assets/index-BPPmlB5x.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-BPPmlB5x.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-C8GNzEPt.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-C8GNzEPt.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-J-FCndLr.js"></script>
|
<script type="module" crossorigin src="./assets/index-BPPmlB5x.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-C8GNzEPt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -104,6 +104,42 @@ extension:
|
|||||||
widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets.
|
widget_failed: This control could not be loaded. Refresh the page; if it happens again, check the plugin's assets.
|
||||||
partial_failed: This section could not be loaded. Refresh the page to try again.
|
partial_failed: This section could not be loaded. Refresh the page to try again.
|
||||||
action_failed: The action could not be completed. Please try again.
|
action_failed: The action could not be completed. Please try again.
|
||||||
|
fileupload:
|
||||||
|
attachment: Attachment details
|
||||||
|
help: Add a title and description for this attachment.
|
||||||
|
title_label: Title
|
||||||
|
description_label: Description
|
||||||
|
default_prompt: Drag a file here or click to upload
|
||||||
|
default_prompt_many: Drag files here or click to upload
|
||||||
|
upload_file: Upload file
|
||||||
|
replace_file: Replace file
|
||||||
|
remove_file: Remove file
|
||||||
|
upload_error: Upload error
|
||||||
|
upload_failed: The file could not be uploaded. Try again.
|
||||||
|
retry: Retry
|
||||||
|
too_large: ":name is larger than :size. Choose a smaller file."
|
||||||
|
wrong_type: ":name is not an allowed file type. Allowed: :types."
|
||||||
|
too_many:
|
||||||
|
one: "File limit reached (:max). :count file was not added."
|
||||||
|
other: "File limit reached (:max). :count files were not added."
|
||||||
|
limits_types: "Allowed: :types"
|
||||||
|
limits_size: "up to :size"
|
||||||
|
limits_count: "Files: :current of :max"
|
||||||
|
uploading: "Uploading… :percent%"
|
||||||
|
queued: Waiting…
|
||||||
|
unsaved: Unsaved
|
||||||
|
move: "Move: :name"
|
||||||
|
moved: "Moved :name to position :position of :total."
|
||||||
|
reorder_failed: The new order could not be saved. The previous order is back.
|
||||||
|
edit_details: "Edit details: :name"
|
||||||
|
download: "Download: :name"
|
||||||
|
open_preview: "Open: :name"
|
||||||
|
preview_unavailable: No preview
|
||||||
|
load_failed: The files could not be loaded. Refresh the page to try again.
|
||||||
|
cancel_upload: "Cancel upload: :name"
|
||||||
|
details_saved: Attachment details saved
|
||||||
|
details_submit: Save details
|
||||||
|
empty: No files
|
||||||
messages:
|
messages:
|
||||||
list:
|
list:
|
||||||
record_count:
|
record_count:
|
||||||
|
|||||||
@@ -112,6 +112,44 @@ extension:
|
|||||||
widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.
|
widget_failed: Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.
|
||||||
partial_failed: Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.
|
partial_failed: Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.
|
||||||
action_failed: Nie udało się wykonać akcji. Spróbuj ponownie.
|
action_failed: Nie udało się wykonać akcji. Spróbuj ponownie.
|
||||||
|
fileupload:
|
||||||
|
attachment: Szczegóły załącznika
|
||||||
|
help: Dodaj tytuł oraz opis załącznika.
|
||||||
|
title_label: Tytuł
|
||||||
|
description_label: Opis
|
||||||
|
default_prompt: Przeciągnij plik tutaj lub kliknij, aby przesłać
|
||||||
|
default_prompt_many: Przeciągnij pliki tutaj lub kliknij, aby przesłać
|
||||||
|
upload_file: Prześlij plik
|
||||||
|
replace_file: Zamień plik
|
||||||
|
remove_file: Usuń plik
|
||||||
|
upload_error: Błąd przesyłania
|
||||||
|
upload_failed: Nie udało się przesłać pliku. Spróbuj ponownie.
|
||||||
|
retry: Ponów
|
||||||
|
too_large: "Plik :name jest większy niż :size. Wybierz mniejszy plik."
|
||||||
|
wrong_type: "Plik :name ma niedozwolony typ. Dozwolone: :types."
|
||||||
|
too_many:
|
||||||
|
one: "Osiągnięto limit plików (:max). Nie dodano :count pliku."
|
||||||
|
few: "Osiągnięto limit plików (:max). Nie dodano :count plików."
|
||||||
|
many: "Osiągnięto limit plików (:max). Nie dodano :count plików."
|
||||||
|
other: "Osiągnięto limit plików (:max). Nie dodano :count plików."
|
||||||
|
limits_types: "Dozwolone: :types"
|
||||||
|
limits_size: "do :size"
|
||||||
|
limits_count: "Pliki: :current z :max"
|
||||||
|
uploading: "Przesyłanie… :percent%"
|
||||||
|
queued: Oczekuje…
|
||||||
|
unsaved: Niezapisany
|
||||||
|
move: "Przenieś: :name"
|
||||||
|
moved: "Przeniesiono :name na pozycję :position z :total."
|
||||||
|
reorder_failed: Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.
|
||||||
|
edit_details: "Edytuj szczegóły: :name"
|
||||||
|
download: "Pobierz: :name"
|
||||||
|
open_preview: "Otwórz: :name"
|
||||||
|
preview_unavailable: Brak podglądu
|
||||||
|
load_failed: Nie udało się wczytać plików. Odśwież stronę, aby spróbować ponownie.
|
||||||
|
cancel_upload: "Anuluj przesyłanie: :name"
|
||||||
|
details_saved: Zapisano szczegóły załącznika
|
||||||
|
details_submit: Zapisz szczegóły
|
||||||
|
empty: Brak plików
|
||||||
messages:
|
messages:
|
||||||
list:
|
list:
|
||||||
record_count:
|
record_count:
|
||||||
|
|||||||
Reference in New Issue
Block a user