test(12.2-05): SPA unit tests for the phase and the check-phase12.2 gate

- sessionKey, dateFormat, FileuploadField (protected thumbnails and
  keyboard reorder backstops), RelationChildModal, RelationPivotModal,
  RelationManager row actions and create-screen deferral, date and time
  list cells; typed deferred relation-schema and file-list fixtures
- scripts/check-phase12.2.sh: go, security (named tests, refuses missing
  or skipped), spa, openapi, dist, docs, hygiene and app stages, a
  detector self-test, one PASS or FAIL line per stage under --all
This commit is contained in:
Jakub Zych
2026-10-02 20:51:36 +02:00
parent 5b06b20ac3
commit d5494faa81
11 changed files with 1856 additions and 4 deletions

View File

@@ -0,0 +1,208 @@
// Datepicker value conversions (Phase 12.2, D-18 to D-21): parse and emit
// per mode, ignoreTimezone, display formats, week start and bounds. The
// datetime cases run in a fixed non-UTC zone.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
CalendarDate,
CalendarDateTime,
Time,
ZonedDateTime,
resetLocalTimeZone,
setLocalTimeZone,
} from '@internationalized/date'
import {
DEFAULT_DISPLAY,
boundValue,
dateMode,
dayBounds,
emitFieldValue,
formatDisplay,
parseFieldValue,
startValue,
weekStart,
} from '../../src/app/dateFormat'
beforeEach(() => setLocalTimeZone('Europe/Warsaw'))
afterEach(() => resetLocalTimeZone())
describe('dateMode', () => {
it('defaults to datetime', () => {
expect(dateMode('date')).toBe('date')
expect(dateMode('time')).toBe('time')
expect(dateMode('datetime')).toBe('datetime')
expect(dateMode(undefined)).toBe('datetime')
expect(dateMode(null)).toBe('datetime')
expect(dateMode('week')).toBe('datetime')
})
})
describe('parseFieldValue', () => {
it('reads null, empty and unreadable values as null', () => {
for (const mode of ['date', 'datetime', 'time'] as const) {
expect(parseFieldValue(mode, null)).toBeNull()
expect(parseFieldValue(mode, undefined)).toBeNull()
expect(parseFieldValue(mode, '')).toBeNull()
expect(parseFieldValue(mode, ' ')).toBeNull()
expect(parseFieldValue(mode, 42)).toBeNull()
expect(parseFieldValue(mode, 'garbage')).toBeNull()
}
})
it('reads a date as a calendar day, ignoring any time part', () => {
expect(parseFieldValue('date', '2026-10-02')?.toString()).toBe('2026-10-02')
expect(parseFieldValue('date', '2026-10-02T23:30:00Z')?.toString()).toBe('2026-10-02')
expect(parseFieldValue('date', '2026-10-02')).toBeInstanceOf(CalendarDate)
})
it('reads a time with or without seconds or a date', () => {
expect(parseFieldValue('time', '14:30')?.toString()).toBe('14:30:00')
expect(parseFieldValue('time', '14:30:15')?.toString()).toBe('14:30:15')
expect(parseFieldValue('time', '2026-10-02T08:05:00Z')?.toString()).toBe('08:05:00')
expect(parseFieldValue('time', '14:30')).toBeInstanceOf(Time)
})
it('shows a datetime in the local zone, a zone-less one read as UTC', () => {
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime
expect(zoned).toBeInstanceOf(ZonedDateTime)
expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([12, 30])
const winter = parseFieldValue('datetime', '2026-01-15 10:30:00') as ZonedDateTime
expect([winter.hour, winter.minute]).toEqual([11, 30])
const offset = parseFieldValue('datetime', '2026-10-02T10:30:00+02:00') as ZonedDateTime
expect([offset.hour, offset.minute]).toEqual([10, 30])
})
it('keeps the UTC wall clock with ignoreTimezone', () => {
const wall = parseFieldValue('datetime', '2026-10-02T10:30:00Z', true)
expect(wall).toBeInstanceOf(CalendarDateTime)
expect(wall?.toString()).toBe('2026-10-02T10:30:00')
})
})
describe('emitFieldValue', () => {
it('emits null for no value', () => {
expect(emitFieldValue('date', null)).toBeNull()
expect(emitFieldValue('datetime', undefined)).toBeNull()
expect(emitFieldValue('time', null)).toBeNull()
})
it('emits a date as YYYY-MM-DD and a time as HH:MM:SS', () => {
expect(emitFieldValue('date', new CalendarDate(26, 1, 2))).toBe('0026-01-02')
expect(emitFieldValue('date', new CalendarDateTime(2026, 10, 2, 23, 59))).toBe('2026-10-02')
expect(emitFieldValue('time', new Time(7, 5))).toBe('07:05:00')
expect(emitFieldValue('time', new CalendarDate(2026, 10, 2))).toBeNull()
expect(emitFieldValue('date', new Time(7, 5))).toBeNull()
})
it('emits a local datetime as a UTC instant without milliseconds', () => {
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30))).toBe('2026-10-02T10:30:00Z')
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 1, 1, 0, 30))).toBe('2025-12-31T23:30:00Z')
expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2))).toBe('2026-10-01T22:00:00Z')
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z')
expect(emitFieldValue('datetime', zoned)).toBe('2026-10-02T10:30:00Z')
})
it('emits the wall clock unchanged with ignoreTimezone', () => {
expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30), true)).toBe('2026-10-02T12:30:00Z')
const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime
expect(emitFieldValue('datetime', zoned, true)).toBe('2026-10-02T12:30:00Z')
expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2), true)).toBe('2026-10-02T00:00:00Z')
})
it('round-trips every mode', () => {
for (const [mode, text] of [
['date', '2026-02-28'],
['time', '23:59:59'],
['datetime', '2026-03-29T01:30:00Z'],
] as const) {
expect(emitFieldValue(mode, parseFieldValue(mode, text))).toBe(text)
}
expect(emitFieldValue('datetime', parseFieldValue('datetime', '2026-10-02T10:30:00Z', true), true)).toBe('2026-10-02T10:30:00Z')
})
})
describe('formatDisplay', () => {
const day = new CalendarDate(2026, 10, 2)
const at = new CalendarDateTime(2026, 10, 2, 14, 5, 9)
it('uses the mode default without a display format', () => {
expect(formatDisplay(day, 'date', undefined, 'en')).toBe('2026-10-02')
expect(formatDisplay(at, 'datetime', '', 'en')).toBe('2026-10-02 14:05')
expect(formatDisplay(new Time(9, 3), 'time', null, 'en')).toBe('09:03')
expect(DEFAULT_DISPLAY).toEqual({ date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm', time: 'HH:mm' })
expect(formatDisplay(null, 'date', 'DD', 'en')).toBe('')
})
it('renders every token', () => {
expect(formatDisplay(at, 'datetime', 'DD.MM.YYYY HH:mm:ss', 'pl')).toBe('02.10.2026 14:05:09')
expect(formatDisplay(at, 'datetime', 'D/M/YY H:mm', 'en')).toBe('2/10/26 14:05')
expect(formatDisplay(at, 'datetime', 'hh:mm A', 'en')).toBe('02:05 PM')
expect(formatDisplay(new CalendarDateTime(2026, 10, 2, 0, 5), 'datetime', 'h:mm a', 'en')).toBe('12:05 am')
expect(formatDisplay(at, 'datetime', 'dddd, D MMMM YYYY', 'en')).toBe('Friday, 2 October 2026')
expect(formatDisplay(at, 'datetime', 'ddd D MMM', 'en')).toBe('Fri 2 Oct')
expect(formatDisplay(at, 'datetime', 'YYYY-MM-DD [at] HH:mm', 'en')).toBe('2026-10-02 at 14:05')
})
it('names months and days in the admin locale, genitive next to a day', () => {
expect(formatDisplay(day, 'date', 'D MMMM YYYY', 'pl')).toBe('2 października 2026')
expect(formatDisplay(day, 'date', 'MMMM YYYY', 'pl')).toBe('październik 2026')
expect(formatDisplay(day, 'date', 'dddd', 'pl')).toBe('piątek')
})
it('leaves clock tokens empty on a date and date tokens empty on a time', () => {
expect(formatDisplay(day, 'date', 'YYYY-MM-DD HH:mm', 'en')).toBe('2026-10-02 :')
expect(formatDisplay(new Time(9, 3), 'time', 'DD HH:mm', 'en')).toBe(' 09:03')
})
})
describe('weekStart', () => {
it('takes an explicit firstDay', () => {
expect(weekStart('pl', 0)).toBe(0)
expect(weekStart('en-US', 6)).toBe(6)
})
it('falls back to the locale for a missing or invalid firstDay', () => {
expect(weekStart('pl')).toBe(1)
expect(weekStart('en-US')).toBe(0)
expect(weekStart('pl', 7)).toBe(1)
expect(weekStart('pl', 1.5)).toBe(1)
expect(weekStart('en-US', null)).toBe(0)
})
})
describe('dayBounds and boundValue', () => {
const now = new CalendarDate(2026, 10, 2)
it('prefers minDate and maxDate over yearRange', () => {
const bounds = dayBounds({ minDate: '2026-01-01', maxDate: '2026-12-31T00:00:00Z', yearRange: [5] }, now)
expect(bounds.min?.toString()).toBe('2026-01-01')
expect(bounds.max?.toString()).toBe('2026-12-31')
expect(dayBounds({ minDate: 'garbage' }, now)).toEqual({})
})
it('reads both yearRange forms', () => {
const around = dayBounds({ yearRange: [10] }, now)
expect([around.min?.toString(), around.max?.toString()]).toEqual(['2016-01-01', '2036-12-31'])
const span = dayBounds({ yearRange: [2000, 2040] }, now)
expect([span.min?.toString(), span.max?.toString()]).toEqual(['2000-01-01', '2040-12-31'])
expect(dayBounds({ yearRange: [] }, now)).toEqual({})
})
it('turns a bound day into the start or end of that day per mode', () => {
const day = new CalendarDate(2026, 10, 2)
expect(boundValue(day, 'date', false, true)?.toString()).toBe('2026-10-02')
expect(boundValue(day, 'time', false, true)).toBeUndefined()
expect(boundValue(undefined, 'date', false, true)).toBeUndefined()
expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59')
const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime
expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([0, 0])
})
it('starts a fresh picker at today in its value type', () => {
expect(startValue('date', false)).toBeInstanceOf(CalendarDate)
expect(startValue('datetime', true)).toBeInstanceOf(CalendarDateTime)
expect(startValue('datetime', false)).toBeInstanceOf(ZonedDateTime)
expect(startValue('time', false)).toBeInstanceOf(ZonedDateTime)
})
})

View File

@@ -0,0 +1,46 @@
// Form session keys (Phase 12.2, D-02): 32 random bytes from
// crypto.getRandomValues as unpadded base64url, 43 characters, never the
// same twice, and only ever sent in the two headers.
import { describe, expect, it, vi } from 'vitest'
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../src/app/sessionKey'
const SERVER_PATTERN = /^[A-Za-z0-9_-]{32,128}$/
describe('newSessionKey', () => {
it('is 43 base64url characters the server accepts', () => {
for (let i = 0; i < 50; i++) {
const key = newSessionKey()
expect(key).toHaveLength(43)
expect(key).toMatch(/^[A-Za-z0-9_-]+$/)
expect(key).toMatch(SERVER_PATTERN)
expect(key).not.toContain('=')
}
})
it('draws 32 bytes from crypto.getRandomValues and encodes them', () => {
const spy = vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
const bytes = array as unknown as Uint8Array
bytes.fill(0xfb)
bytes[31] = 0xff
return array
})
const key = newSessionKey()
expect(spy).toHaveBeenCalledTimes(1)
const arg = spy.mock.calls[0]![0] as unknown as Uint8Array
expect(arg).toBeInstanceOf(Uint8Array)
expect(arg.byteLength).toBe(32)
// 0xfb bytes give "+" and "/" in standard base64: base64url turns them
// into "-" and "_".
expect(key).toBe('-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_8')
})
it('differs on every call', () => {
const keys = new Set(Array.from({ length: 200 }, () => newSessionKey()))
expect(keys.size).toBe(200)
})
it('names the two headers', () => {
expect(SESSION_HEADER).toBe('X-Session-Key')
expect(CHILD_SESSION_HEADER).toBe('X-Child-Session-Key')
})
})

View File

@@ -0,0 +1,38 @@
{
"data": [
{
"id": 41,
"file_name": "front.png",
"file_size": 120400,
"content_type": "image/png",
"title": "Front",
"description": "",
"sort_order": 41,
"pending": false,
"created_at": "2026-10-02T10:00:00Z"
},
{
"id": 42,
"file_name": "back.png",
"file_size": 98000,
"content_type": "image/png",
"title": "",
"description": "",
"sort_order": 42,
"pending": true,
"created_at": "2026-10-02T10:05:00Z"
},
{
"id": 43,
"file_name": "manual.pdf",
"file_size": 2400000,
"content_type": "application/pdf",
"title": "",
"description": "",
"sort_order": 43,
"pending": false,
"created_at": "2026-10-02T10:10:00Z"
}
],
"meta": {}
}

View File

@@ -0,0 +1,287 @@
{
"parts": {
"data": {
"name": "parts",
"label": "Parts",
"kind": "hasMany",
"deferrable": true,
"view": {
"list": {
"columns": [
{
"key": "label",
"label": "Label",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [
"create",
"update",
"delete",
"link",
"unlink"
],
"showSearch": false
},
"manage": {
"list": {
"columns": [
{
"key": "label",
"label": "Label",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [],
"showSearch": false
},
"messages": {
"link": {
"other": "Add member"
},
"linkHint": {
"other": "Pick people who are not members yet. The owner is not listed."
},
"candidateSearch": {
"other": "Search by name or email…"
},
"linked": {
"one": "Added :count member",
"other": "Added :count members"
},
"unlinkSelected": {
"other": "Remove selected"
},
"unlinkConfirm": {
"one": "Remove :count member?",
"other": "Remove :count members?"
},
"unlinked": {
"one": "Removed :count member",
"other": "Removed :count members"
},
"empty": {
"other": "This widget has no members."
},
"create": {
"other": "New record"
},
"createTitle": {
"other": "New record"
},
"updateTitle": {
"other": "Edit record"
},
"previewTitle": {
"other": "Record preview"
},
"created": {
"other": "Record created"
},
"updated": {
"other": "Record saved"
},
"deleteSelected": {
"other": "Delete selected"
},
"deleteConfirm": {
"other": "Delete the selected (:count)? This cannot be undone."
},
"deleteOneConfirm": {
"other": "Delete this record? This cannot be undone."
},
"deleted": {
"one": "Deleted :count record",
"other": "Deleted :count records"
},
"pivotTitle": {
"other": "Link details"
},
"pivotSaved": {
"other": "Link details saved"
},
"editPivot": {
"other": "Edit link details: :name"
},
"createSubmit": {
"other": "Create record"
},
"updateSubmit": {
"other": "Save record"
},
"pivotSubmit": {
"other": "Save link details"
},
"linkSubmit": {
"other": "Add link"
}
},
"manageForm": [
{
"name": "label",
"label": "Label",
"type": "text",
"required": true
},
{
"name": "notes",
"label": "Notes",
"type": "textarea",
"tab": "Details"
}
],
"viewForm": [
{
"name": "label",
"label": "Label",
"type": "text"
}
]
},
"meta": {
"locale": "en"
}
},
"members": {
"data": {
"name": "members",
"label": "Members",
"kind": "belongsToMany",
"deferrable": true,
"view": {
"list": {
"columns": [
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [
"link",
"unlink"
],
"showSearch": false
},
"manage": {
"list": {
"columns": [
{
"key": "email",
"label": "E-mail",
"searchable": true,
"sortable": true
}
]
},
"toolbarButtons": [],
"showSearch": false
},
"messages": {
"link": {
"other": "Add member"
},
"linkHint": {
"other": "Pick people who are not members yet. The owner is not listed."
},
"candidateSearch": {
"other": "Search by name or email…"
},
"linked": {
"one": "Added :count member",
"other": "Added :count members"
},
"unlinkSelected": {
"other": "Remove selected"
},
"unlinkConfirm": {
"one": "Remove :count member?",
"other": "Remove :count members?"
},
"unlinked": {
"one": "Removed :count member",
"other": "Removed :count members"
},
"empty": {
"other": "This widget has no members."
},
"create": {
"other": "New record"
},
"createTitle": {
"other": "New record"
},
"updateTitle": {
"other": "Edit record"
},
"previewTitle": {
"other": "Record preview"
},
"created": {
"other": "Record created"
},
"updated": {
"other": "Record saved"
},
"deleteSelected": {
"other": "Delete selected"
},
"deleteConfirm": {
"other": "Delete the selected (:count)? This cannot be undone."
},
"deleteOneConfirm": {
"other": "Delete this record? This cannot be undone."
},
"deleted": {
"one": "Deleted :count record",
"other": "Deleted :count records"
},
"pivotTitle": {
"other": "Link details"
},
"pivotSaved": {
"other": "Link details saved"
},
"editPivot": {
"other": "Edit link details: :name"
},
"createSubmit": {
"other": "Create record"
},
"updateSubmit": {
"other": "Save record"
},
"pivotSubmit": {
"other": "Save link details"
},
"linkSubmit": {
"other": "Add link"
}
},
"viewForm": [
{
"name": "email",
"label": "E-mail",
"type": "text"
}
],
"pivotForm": [
{
"name": "note",
"label": "Note",
"type": "text",
"required": true
}
]
},
"meta": {
"locale": "en"
}
}
}

View File

@@ -10,7 +10,9 @@ 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 deferredFilesJson from './deferred.files.json'
import deferredFormSchemaJson from './deferred.form-schema.json'
import deferredRelationSchemaJson from './deferred.relation-schema.json'
import formSchemaJson from './widgets.form-schema.json'
import listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json'
@@ -33,6 +35,13 @@ export const settingsFixture: {
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
/** Relation schemas with child, view and pivot forms: a hasMany and a belongsToMany (Phase 12.2). */
export const deferredRelationSchemaFixture: {
parts: S['cabana.Envelope-cabana_RelationSchema']
members: S['cabana.Envelope-cabana_RelationSchema']
} = deferredRelationSchemaJson
/** A protected file list: a captioned image, a pending image and a PDF (Phase 12.2). */
export const deferredFilesFixture: S['cabana.Envelope-array_cabana_FileItem'] = deferredFilesJson
/** 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

@@ -0,0 +1,437 @@
// FileuploadField (Phase 12.2, D-03, D-08 to D-10; UI-SPEC section 3)
// mounted on its own with the form session FormView would provide. HTTP
// goes through the fetch mock and real file route adapters, uploads
// through a hand-answered fake XMLHttpRequest. Covers UI-SPEC backstops 3
// (protected thumbnails: session header, object URL, revoked on unmount)
// and 4 (keyboard reorder: focus kept, announced, one debounced request).
// The language bundle has no fileupload strings, so texts are their keys.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import FileuploadField from '../../src/components/form/fields/FileuploadField.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { parentFileRoutes } from '../../src/api/files'
import type { FileItem, FormField } from '../../src/api/types'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { deferredFilesFixture } from '../fixtures/typed'
import { API, keydown, mockApi, requestsTo, resetState, wait, type Route } from '../helpers'
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
const KEY = 'k'.repeat(43)
const BASE = `${API}/acme/demo/gadgets`
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 photos(extra: Partial<FormField> = {}): FormField {
return {
name: 'photos',
label: 'Photos',
type: 'fileupload',
mode: 'image',
multiple: true,
maxFiles: 3,
fileTypes: ['jpg', 'png'],
maxFilesize: 2,
useCaption: true,
...extra,
} as FormField
}
function item(id: number, name: string, extra: Partial<FileItem> = {}): FileItem {
return {
id,
file_name: name,
file_size: 1200,
content_type: 'image/png',
title: '',
description: '',
sort_order: id,
pending: false,
created_at: '2026-10-02T10:00:00Z',
url: `/storage/app/uploads/public/${name}`,
thumb_url: `/storage/app/uploads/public/thumb_${name}`,
...extra,
}
}
function listOf(files: FileItem[]): Route {
return { body: { data: files, meta: {} } }
}
interface Setup {
field?: FormField
recordId?: number
routes?: Record<string, Route>
}
async function setup(options: Setup = {}) {
const recordId = options.recordId ?? 7
const field = options.field ?? photos()
const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) })
const markDirty = vi.fn()
const session: FormSession = {
key: KEY,
recordId,
routes: (name: string) => parentFileRoutes(SOURCE, recordId, name, KEY),
markDirty,
pendingChanges: ref(0),
revision: ref(0),
}
const wrapper = mount(FileuploadField, {
props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' },
global: { provide: { [FORM_SESSION as symbol]: session } },
attachTo: document.body,
})
await flushPromises()
return { wrapper, calls, markDirty }
}
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' })
}
function names(wrapper: VueWrapper): string[] {
return wrapper.findAll('[data-file-item]').map((li) => li.find('img').attributes('alt') ?? li.text())
}
// URL.createObjectURL and revokeObjectURL replaced for one test; the
// originals come back in afterEach.
const originalUrls = { create: URL.createObjectURL, revoke: URL.revokeObjectURL }
function objectUrls(create: (blob: Blob) => string, revoke: (url: string) => void): void {
URL.createObjectURL = create as typeof URL.createObjectURL
URL.revokeObjectURL = revoke
}
beforeEach(() => {
resetState()
FakeXHR.sent = []
vi.stubGlobal('XMLHttpRequest', FakeXHR)
})
afterEach(() => {
vi.unstubAllGlobals()
URL.createObjectURL = originalUrls.create
URL.revokeObjectURL = originalUrls.revoke
document.body.innerHTML = ''
})
describe('dropzone and client checks', () => {
it('shows the prompt and the limits line, and accepts the allowed types', async () => {
const { wrapper, calls } = await setup()
const [list] = requestsTo(calls, 'GET', `${BASE}/7/files/photos`)
expect(list!.headers.get(SESSION_HEADER)).toBe(KEY)
const zone = wrapper.find('[data-fileupload-dropzone]')
expect(zone.text()).toContain('backend::lang.fileupload.default_prompt_many')
expect(wrapper.find('[data-fileupload-limits]').text()).toBe(
'backend::lang.fileupload.limits_types · backend::lang.fileupload.limits_size · backend::lang.fileupload.limits_count',
)
expect(wrapper.find('[data-fileupload-input]').attributes('accept')).toBe('.jpg,.png')
expect(wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeDefined()
const single = await setup({ field: photos({ name: 'cover', multiple: false, prompt: 'Drop a cover' }), routes: { [`GET ${BASE}/7/files/cover`]: listOf([]) } })
expect(single.wrapper.find('[data-fileupload-dropzone]').text()).toContain('Drop a cover')
expect(single.wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeUndefined()
})
it('refuses a wrong type and an oversized file and reports files past maxFiles', async () => {
const { wrapper } = await setup()
await choose(wrapper, [new File(['x'], 'a.gif', { type: 'image/gif' }), png('big.png', 3 << 20), png('a.png'), png('b.png'), png('c.png'), png('d.png'), png('e.png')])
const failed = wrapper.findAll('[data-state="failed"]')
expect(failed.map((f) => f.find('[data-file-error]').text())).toEqual([
'backend::lang.fileupload.upload_error: backend::lang.fileupload.wrong_type',
'backend::lang.fileupload.upload_error: backend::lang.fileupload.too_large',
])
const alert = wrapper.find('[data-fileupload-too-many]')
expect(alert.attributes('role')).toBe('alert')
expect(alert.text()).toContain('backend::lang.fileupload.too_many')
expect(FakeXHR.sent).toHaveLength(1)
expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2)
})
})
describe('upload states', () => {
it('goes queued, uploading with progress, done; a server failure can be retried', async () => {
const { wrapper, markDirty } = await setup()
await choose(wrapper, [png('a.png'), png('b.png')])
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued')
const first = FakeXHR.sent[0]!
expect(first.headers[SESSION_HEADER]).toBe(KEY)
first.progress(30, 60)
await flushPromises()
expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true)
first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } })
await flushPromises()
expect(markDirty).not.toHaveBeenCalled()
const failed = wrapper.find('[data-state="failed"]')
expect(failed.find('[data-file-error]').text()).toContain('Server error')
// The next file is sent as soon as the first one failed.
expect(FakeXHR.sent).toHaveLength(2)
FakeXHR.sent[1]!.respond(201, { data: item(51, 'b.png', { pending: true }), meta: {} })
await flushPromises()
expect(markDirty).toHaveBeenCalledTimes(1)
await failed.find('[data-action="retry"]').trigger('click')
await flushPromises()
expect(FakeXHR.sent).toHaveLength(3)
expect((FakeXHR.sent[2]!.body!.get('file_data') as File).name).toBe('a.png')
FakeXHR.sent[2]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} })
await flushPromises()
expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2)
expect(markDirty).toHaveBeenCalledTimes(2)
})
it('shows the Unsaved chip on pending files of a saved record only', async () => {
const pending = [item(41, 'a.png', { pending: true }), item(42, 'b.png')]
const update = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: listOf(pending) } })
expect(update.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(1)
const create = await setup({ recordId: 0, routes: { [`GET ${BASE}/0/files/photos`]: listOf(pending) } })
expect(create.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(0)
})
it('removes a saved file through the session and marks the form dirty', async () => {
const { wrapper, calls, markDirty } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png'), item(42, 'b.png')]),
[`DELETE ${BASE}/7/files/photos/41`]: { body: { data: { removed: true, pending: true }, meta: {} } },
},
})
await wrapper.findAll('[data-action="remove-file"]')[0]!.trigger('click')
await flushPromises()
const [remove] = requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/41`)
expect(remove!.headers.get(SESSION_HEADER)).toBe(KEY)
expect(markDirty).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('[data-file-item]')).toHaveLength(1)
})
it('puts a file back and toasts when the removal fails', async () => {
const { wrapper } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
[`DELETE ${BASE}/7/files/photos/41`]: { status: 404, body: { error: { code: 'not_found', message: 'Gone', details: {} } } },
},
})
await wrapper.find('[data-action="remove-file"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-file-item]')).toHaveLength(1)
expect(toasts.value.at(-1)).toMatchObject({ text: 'Gone', tone: 'danger' })
})
})
describe('protected thumbnails (backstop 3)', () => {
it('fetches the thumb with the session header, shows it from an object URL and revokes it on unmount', async () => {
const create = vi.fn(() => 'blob:thumb-41')
const revoke = vi.fn()
objectUrls(create, revoke)
const { wrapper, calls } = await setup({
field: photos({ protected: true }),
routes: {
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]),
[`GET ${BASE}/7/files/photos/41/thumb`]: () => ({ body: 'png-bytes' }),
},
})
await flushPromises()
const [thumb] = requestsTo(calls, 'GET', `${BASE}/7/files/photos/41/thumb`)
expect(thumb).toBeDefined()
expect(thumb!.headers.get(SESSION_HEADER)).toBe(KEY)
expect(new URL(thumb!.url).search).toBe('')
expect(create).toHaveBeenCalledTimes(1)
expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:thumb-41')
expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false)
expect(revoke).not.toHaveBeenCalled()
wrapper.unmount()
expect(revoke).toHaveBeenCalledWith('blob:thumb-41')
})
it('shows no preview when the thumb cannot be fetched', async () => {
objectUrls(vi.fn(() => 'blob:x'), vi.fn())
const { wrapper } = await setup({
field: photos({ protected: true }),
routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]) },
})
await flushPromises()
expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true)
})
})
describe('keyboard reorder (backstop 4)', () => {
const three = [item(41, 'a.png'), item(42, 'b.png'), item(43, 'c.png')]
it('moves the item, keeps focus on its handle, announces the move and sends one request after 400ms', async () => {
const { wrapper, calls } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf(three),
[`POST ${BASE}/7/files/photos/reorder`]: { body: { data: [three[1], three[2], three[0]], meta: {} } },
},
})
const handle = wrapper.findAll('[data-handle]')[0]!
const uid = handle.attributes('data-handle')
;(handle.element as HTMLElement).focus()
keydown(handle.element, 'ArrowDown')
await flushPromises()
let focused = document.activeElement as HTMLElement
expect(focused.dataset.handle).toBe(uid)
keydown(focused, 'ArrowDown')
await flushPromises()
focused = document.activeElement as HTMLElement
expect(focused.dataset.handle).toBe(uid)
expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png'])
expect(wrapper.find('[data-fileupload-live]').text()).toBe('backend::lang.fileupload.moved')
// Up at the top and down at the bottom do nothing.
keydown(focused, 'ArrowDown')
await flushPromises()
expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png'])
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(0)
await wait(450)
await flushPromises()
const sent = requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)
expect(sent).toHaveLength(1)
expect(sent[0]!.headers.get(SESSION_HEADER)).toBe(KEY)
expect(await sent[0]!.clone().json()).toEqual({ ids: [42, 43, 41] })
})
it('restores the order and toasts when the reorder fails', async () => {
const { wrapper } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf(three),
[`POST ${BASE}/7/files/photos/reorder`]: { status: 422, body: { error: { code: 'validation_failed', message: 'x', details: {} } } },
},
})
keydown(wrapper.findAll('[data-handle]')[2]!.element, 'ArrowUp')
await flushPromises()
expect(names(wrapper)).toEqual(['a.png', 'c.png', 'b.png'])
await wait(450)
await flushPromises()
expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png'])
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' })
})
})
describe('caption modal', () => {
it('saves the title and description at once and shows a 422 under its input', async () => {
let reply: Route = { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { title: ['The title may not be greater than 255 characters.'] } } } }
const { wrapper, calls, markDirty } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]),
[`PUT ${BASE}/7/files/photos/41`]: (request) => (typeof reply === 'function' ? reply(request) : reply),
},
})
await wrapper.find('[data-caption]').trigger('click')
await flushPromises()
const modal = document.body.querySelector<HTMLElement>('[data-caption-modal]')!
expect(modal).not.toBeNull()
const title = modal.querySelector<HTMLInputElement>('[data-caption-title]')!
title.value = 'Front'
title.dispatchEvent(new Event('input'))
;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click()
await flushPromises()
expect(title.getAttribute('aria-invalid')).toBe('true')
expect(modal.textContent).toContain('The title may not be greater than 255 characters.')
reply = { body: { data: item(41, 'a.png', { title: 'Front' }), meta: {} } }
;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click()
await flushPromises()
const puts = requestsTo(calls, 'PUT', `${BASE}/7/files/photos/41`)
expect(puts).toHaveLength(2)
expect(puts[1]!.headers.get(SESSION_HEADER)).toBe(KEY)
expect(await puts[1]!.clone().json()).toEqual({ title: 'Front', description: '' })
expect(document.body.querySelector('[data-caption-modal]')).toBeNull()
expect(markDirty).not.toHaveBeenCalled()
})
})
describe('read-only and load failure', () => {
it('renders files without controls when read-only, and an empty line without files', async () => {
const shown = await setup({ field: photos({ readOnly: true }), routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]) } })
expect(shown.wrapper.find('[data-fileupload-dropzone]').exists()).toBe(false)
expect(shown.wrapper.find('[data-action="remove-file"]').exists()).toBe(false)
expect(shown.wrapper.find('[data-handle]').exists()).toBe(false)
expect(shown.wrapper.find('[data-caption]').exists()).toBe(false)
const empty = await setup({ field: photos({ readOnly: true }) })
expect(empty.wrapper.find('[data-fileupload-empty]').text()).toBe('backend::lang.fileupload.empty')
})
it('shows the load_failed alert when the list cannot be read', async () => {
const { wrapper } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } } })
const alert = wrapper.find('[data-fileupload-failed]')
expect(alert.attributes('role')).toBe('alert')
expect(alert.text()).toBe('backend::lang.fileupload.load_failed')
})
})
describe('file rows of a protected field', () => {
it('lists captions and pending files, and downloads through an object URL revoked on unmount', async () => {
const create = vi.fn(() => 'blob:download-43')
const revoke = vi.fn()
objectUrls(create, revoke)
const clicked: string[] = []
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clicked.push(`${this.href}|${this.download}`)
})
const field = photos({ name: 'docs', mode: 'file', protected: true, fileTypes: ['pdf', 'png'], maxFiles: 5 })
const { wrapper, calls } = await setup({
field,
routes: {
[`GET ${BASE}/7/files/docs`]: { body: deferredFilesFixture },
[`GET ${BASE}/7/files/docs/41/thumb`]: { body: 'png' },
[`GET ${BASE}/7/files/docs/42/thumb`]: { body: 'png' },
[`GET ${BASE}/7/files/docs/43/download`]: { body: 'pdf' },
},
})
expect(wrapper.find('[data-fileupload-rows]').exists()).toBe(true)
const rows = wrapper.findAll('[data-file-item]')
expect(rows).toHaveLength(3)
expect(rows[0]!.text()).toContain('Front')
expect(rows[1]!.find('[data-unsaved-chip]').exists()).toBe(true)
expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false)
await rows[2]!.find('[data-action="download"]').trigger('click')
await flushPromises()
const [download] = requestsTo(calls, 'GET', `${BASE}/7/files/docs/43/download`)
expect(download!.headers.get(SESSION_HEADER)).toBe(KEY)
expect(clicked).toEqual(['blob:download-43|manual.pdf'])
click.mockRestore()
wrapper.unmount()
expect(revoke).toHaveBeenCalledWith('blob:download-43')
})
})

View File

@@ -75,3 +75,28 @@ describe('cell value (D-12)', () => {
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
})
})
describe('date and time cells (Phase 12.2)', () => {
const date = { ...listColumn('created_at'), key: 'released_on', type: 'date' } as ListColumn
const time = { ...listColumn('created_at'), key: 'opens_at', type: 'time' } as ListColumn
it('shows a stored date as is, never shifted by a time zone', () => {
expect(cell(date, '2026-10-02').text()).toBe('2026-10-02')
expect(cell(date, '2026-01-01T00:00:00Z').text()).toBe('2026-01-01')
expect(cell(date, '2026-10-02').find('span.tabular-nums').exists()).toBe(true)
})
it('shows a stored time without its seconds', () => {
expect(cell(time, '14:30:15').text()).toBe('14:30')
expect(cell(time, '08:05').text()).toBe('08:05')
expect(cell(time, '14:30:15').find('span.tabular-nums').exists()).toBe(true)
})
it('renders an empty date or time as a muted dash', () => {
for (const column of [date, time]) {
for (const value of [null, undefined, '']) {
expect(cell(column, value).find('[data-empty]').text()).toBe('—')
}
}
})
})

View File

@@ -0,0 +1,209 @@
// RelationChildModal (Phase 12.2, D-11, D-17; UI-SPEC section 4) mounted on
// its own: titles and submit labels per mode, the loading skeleton and the
// load failure alert, a 422 that switches to the invalid field's tab and
// focuses it, the 404 "gone" flow, delete with a confirmation, the modal's
// own session key next to the form's on every call, and the confirmation
// before a dirty close. Texts come from the relation schema fixture.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import RelationChildModal from '../../src/components/relation/RelationChildModal.vue'
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { deferredRelationSchemaFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState, wait, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
const RECORDS = `${API}/acme/demo/gadgets/0/relations/parts/records`
const FORM_KEY = 'f'.repeat(43)
const schema = deferredRelationSchemaFixture.parts.data
const record = { data: { id: 9, label: 'Bolt', notes: 'Steel' }, meta: { labels: {} } }
function body(): HTMLElement {
return document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
}
function setInput(selector: string, value: string): void {
const input = body().querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)!
input.value = value
input.dispatchEvent(new Event('input'))
}
function click(selector: string): void {
;(document.body.querySelector(selector) as HTMLElement).click()
}
async function open(mode: 'create' | 'update' | 'preview', routes: Record<string, Route> = {}, extra: Record<string, unknown> = {}) {
const calls = mockApi(routes)
const wrapper = mount(RelationChildModal, {
props: {
open: true,
source,
ownerId: 0,
relation: 'parts',
schema,
mode,
childId: mode === 'create' ? null : 9,
sessionKey: FORM_KEY,
canDelete: true,
...extra,
},
attachTo: document.body,
})
await flushPromises()
return { wrapper, calls }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('titles and modes', () => {
it('names each mode with its title and submit label', async () => {
const create = await open('create')
expect(body().dataset.mode).toBe('create')
expect(body().querySelector('h2')!.textContent).toBe('New record')
expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Create record')
expect(body().querySelector('[data-action="delete-child"]')).toBeNull()
create.wrapper.unmount()
document.body.innerHTML = ''
const update = await open('update', { [`GET ${RECORDS}/9`]: { body: record } })
expect(body().querySelector('h2')!.textContent).toBe('Edit record')
expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Save record')
expect(body().querySelector('[data-action="delete-child"]')).not.toBeNull()
expect(body().querySelector<HTMLInputElement>('#child-parts-label')!.value).toBe('Bolt')
update.wrapper.unmount()
document.body.innerHTML = ''
await open('preview', { [`GET ${RECORDS}/9`]: { body: record } })
expect(body().querySelector('h2')!.textContent).toBe('Record preview')
expect(body().querySelector('[data-action="save-child"]')).toBeNull()
const input = body().querySelector<HTMLInputElement>('#child-parts-label')!
expect(input.readOnly || input.disabled || input.closest('fieldset')?.disabled).toBeTruthy()
})
it('shows a skeleton while the child loads and an alert when it cannot be read', async () => {
let release: (() => void) | null = null
const slow: Route = () =>
new Promise((resolve) => {
release = () => resolve({ body: record })
})
const { wrapper } = await open('update', { [`GET ${RECORDS}/9`]: slow })
expect(body().querySelector('[data-child-loading]')).not.toBeNull()
release!()
await flushPromises()
expect(body().querySelector('[data-child-loading]')).toBeNull()
wrapper.unmount()
document.body.innerHTML = ''
await open('update', { [`GET ${RECORDS}/9`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } } })
const alert = body().querySelector('[data-child-load-failed]')!
expect(alert).not.toBeNull()
expect(body().querySelector('[data-action="save-child"]')?.hasAttribute('disabled') ?? true).toBe(true)
})
})
describe('saving', () => {
it('sends both keys, the modal key new on every open, and closes with a toast', async () => {
const { wrapper, calls } = await open('create', { [`POST ${RECORDS}`]: { status: 201, body: record } })
setInput('#child-parts-label', 'Bolt')
await flushPromises()
click('[data-action="save-child"]')
await flushPromises()
const [post] = requestsTo(calls, 'POST', RECORDS)
expect(post!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? ''
expect(childKey).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(childKey).not.toBe(FORM_KEY)
expect(await post!.clone().json()).toEqual({ label: 'Bolt' })
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(wrapper.emitted('changed')).toEqual([['created']])
expect(toasts.value.at(-1)).toMatchObject({ text: 'Record created', tone: 'success' })
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
setInput('#child-parts-label', 'Nut')
click('[data-action="save-child"]')
await flushPromises()
const second = requestsTo(calls, 'POST', RECORDS)[1]!
expect(second.headers.get(CHILD_SESSION_HEADER)).not.toBe(childKey)
})
it('switches to the tab of the first invalid field and focuses it on a 422', async () => {
await open('create', {
[`POST ${RECORDS}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { notes: ['The notes field is required.'] } } } },
})
setInput('#child-parts-label', 'Bolt')
click('[data-action="save-child"]')
await flushPromises()
await wait(0)
const notes = body().querySelector<HTMLTextAreaElement>('#child-parts-notes')
expect(notes).not.toBeNull()
expect(document.activeElement).toBe(notes)
expect(body().textContent).toContain('The notes field is required.')
expect(body().querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain('Details')
})
it('closes, toasts child_gone and emits gone on a 404', async () => {
const { wrapper } = await open('update', {
[`GET ${RECORDS}/9`]: { body: record },
[`PUT ${RECORDS}/9`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } },
})
setInput('#child-parts-label', 'Gone')
click('[data-action="save-child"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(wrapper.emitted('gone')).toHaveLength(1)
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
})
it('deletes the child after a confirmation', async () => {
const { wrapper, calls } = await open('update', {
[`GET ${RECORDS}/9`]: { body: record },
[`POST ${API}/acme/demo/gadgets/0/relations/parts/delete`]: { body: { data: { deleted: 1 }, meta: {} } },
})
click('[data-action="delete-child"]')
await flushPromises()
const confirm = document.body.querySelector('[data-confirm-dialog]')!
expect(confirm.textContent).toContain('Delete this record? This cannot be undone.')
click('[data-confirm-dialog] [data-action="confirm"]')
await flushPromises()
const [del] = requestsTo(calls, 'POST', `${API}/acme/demo/gadgets/0/relations/parts/delete`)
expect(await del!.clone().json()).toEqual({ ids: [9] })
expect(del!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
expect(wrapper.emitted('changed')).toEqual([['deleted']])
expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 1 record' })
})
it('asks before closing with unsaved changes', async () => {
const { wrapper } = await open('create')
click('[data-action="close"]')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')).toBeNull()
expect(wrapper.emitted('update:open')).toEqual([[false]])
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
setInput('#child-parts-label', 'Draft')
await flushPromises()
click('[data-relation-child-modal] [data-action="close"]')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
click('[data-confirm-dialog] [data-action="cancel"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toHaveLength(1)
click('[data-relation-child-modal] [data-action="close"]')
await flushPromises()
click('[data-confirm-dialog] [data-action="confirm"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toEqual([[false], [false]])
})
})

View File

@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import { createMemoryHistory } from 'vue-router'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { createAdminRouter } from '../../src/app/router'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
import { clone, deferredRelationSchemaFixture, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
@@ -21,12 +24,19 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
}
}
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
async function mountManager(
overrides: Record<string, Route> = {},
props: Record<string, unknown> = {},
session: FormSession | null = null,
) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationManager, {
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
// DataTable injects the router for its row links.
global: { plugins: [createAdminRouter(createMemoryHistory())] },
global: {
plugins: [createAdminRouter(createMemoryHistory())],
provide: session ? { [FORM_SESSION as symbol]: session } : {},
},
attachTo: document.body,
})
await flushPromises()
@@ -224,3 +234,134 @@ describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
})
})
describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-14)', () => {
const members = deferredRelationSchemaFixture.members
const RECORDS = `${LINKED}/records`
const PIVOT = `${LINKED}/pivot`
const DELETE = `${LINKED}/delete`
const first = linkedFixture.all.data[0]!
function withButtons(buttons: string[], extra: Record<string, unknown> = {}) {
const schema = clone(members)
schema.data.view.toolbarButtons = buttons
Object.assign(schema.data, extra)
return { body: schema }
}
function session(recordId: number): FormSession & { markDirty: ReturnType<typeof vi.fn> } {
return {
key: 'f'.repeat(43),
recordId,
routes: () => {
throw new Error('no file routes in this test')
},
markDirty: vi.fn(),
pendingChanges: ref(0),
revision: ref(0),
}
}
it('opens the pivot modal on a row click when update is not declared', async () => {
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
expect(requestsTo(calls, 'GET', `${PIVOT}/${first.id}`)).toHaveLength(1)
})
it('opens the view form read-only without update and without a pivot form', async () => {
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [] }),
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
expect(modal.dataset.mode).toBe('preview')
expect(requestsTo(calls, 'GET', `${RECORDS}/${first.id}`)).toHaveLength(1)
})
it('offers no row action without update, a pivot form or a view form', async () => {
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [], viewForm: [] }) })
expect(wrapper.find('[data-row-open]').exists()).toBe(false)
})
it('adds a trailing pivot button next to update on a belongsToMany with a pivot form', async () => {
const { wrapper } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['update', 'link'], { manageForm: [{ name: 'email', label: 'E-mail', type: 'text' }] }),
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
})
const buttons = wrapper.findAll('[data-action="edit-pivot"]')
expect(buttons).toHaveLength(linkedFixture.all.data.length)
expect(buttons[0]!.attributes('aria-label')).toBe(`Edit link details: ${first.email}`)
await buttons[0]!.trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
})
it('confirms a delete with the plural count and toasts the plural result', async () => {
const { wrapper } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['delete']),
[`POST ${DELETE}`]: { body: { data: { deleted: 2 }, meta: {} } },
})
const boxes = wrapper.findAll('tbody [role="checkbox"]')
await boxes[0]!.trigger('click')
await boxes[1]!.trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete the selected (2)? This cannot be undone.')
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 2 records', tone: 'success' })
})
it('works on owner 0 with the form key on the create screen and marks the form dirty', async () => {
const field = { ...formField('members'), deferrable: true }
const create = session(0)
const ZERO = `${API}/acme/demo/widgets/0/relations/members`
const { wrapper, calls } = await mountManager(
{
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
[`GET ${ZERO}`]: { body: linkedFixture.all },
[`POST ${ZERO}/unlink`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } },
},
{ field, recordId: null },
create,
)
const manager = wrapper.find('[data-relation-manager]')
expect(manager.attributes('data-deferred')).toBe('true')
expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note')
const [list] = requestsTo(calls, 'GET', ZERO)
expect(list!.headers.get(SESSION_HEADER)).toBe(create.key)
await selectFirstAndUnlink(wrapper)
const [unlink] = requestsTo(calls, 'POST', `${ZERO}/unlink`)
expect(unlink!.headers.get(SESSION_HEADER)).toBe(create.key)
expect(create.markDirty).toHaveBeenCalledTimes(1)
})
it('stays hidden on the create screen when the field is not deferrable', async () => {
const field = { ...formField('members'), deferrable: false }
const { wrapper, calls } = await mountManager({}, { field, recordId: null }, session(0))
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
expect(calls).toHaveLength(0)
})
it('sends no session key and leaves the form clean on a saved record', async () => {
const update = session(1)
const { wrapper, calls } = await mountManager(
{ [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), [`POST ${UNLINK}`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } } },
{ field: { ...formField('members'), deferrable: true } },
update,
)
expect(wrapper.find('[data-relation-pending]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', LINKED)[0]!.headers.get(SESSION_HEADER)).toBeNull()
await selectFirstAndUnlink(wrapper)
expect(requestsTo(calls, 'POST', UNLINK)[0]!.headers.get(SESSION_HEADER)).toBeNull()
expect(update.markDirty).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,119 @@
// RelationPivotModal (Phase 12.2, D-14; UI-SPEC section 4, pivot modal):
// loads the link's pivot form values with the form's session key, saves
// only the pivot form fields, toasts pivot_saved, keeps a 422 in the
// dialog, closes and reloads on a 404, and asks before a dirty close.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import RelationPivotModal from '../../src/components/relation/RelationPivotModal.vue'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { deferredRelationSchemaFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
const PIVOT = `${API}/acme/demo/gadgets/5/relations/members/pivot/12`
const FORM_KEY = 'p'.repeat(43)
const schema = deferredRelationSchemaFixture.members.data
function dialog(): HTMLElement {
return document.body.querySelector<HTMLElement>('[data-relation-pivot-modal]')!
}
function setNote(value: string): void {
const input = dialog().querySelector<HTMLInputElement>('#pivot-members-note')!
input.value = value
input.dispatchEvent(new Event('input'))
}
function click(selector: string): void {
;(document.body.querySelector(selector) as HTMLElement).click()
}
async function open(routes: Record<string, Route>) {
const calls = mockApi(routes)
const wrapper = mount(RelationPivotModal, {
props: { open: true, source, ownerId: 5, relation: 'members', schema, childId: 12, childName: 'ada@example.test', sessionKey: FORM_KEY },
attachTo: document.body,
})
await flushPromises()
return { wrapper, calls }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('relation pivot modal', () => {
it('loads the link details, saves the pivot fields only and toasts pivot_saved', async () => {
const { wrapper, calls } = await open({
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
[`PUT ${PIVOT}`]: { body: { data: { id: 12, note: 'Owner' }, meta: {} } },
})
expect(dialog().textContent).toContain('Link details')
expect(dialog().textContent).toContain('ada@example.test')
const [get] = requestsTo(calls, 'GET', PIVOT)
expect(get!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
expect(dialog().querySelector<HTMLInputElement>('#pivot-members-note')!.value).toBe('Lead')
setNote('Owner')
await flushPromises()
click('[data-action="save-pivot"]')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', PIVOT)
expect(await put!.clone().json()).toEqual({ note: 'Owner' })
expect(put!.headers.get(SESSION_HEADER)).toBe(FORM_KEY)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(toasts.value.at(-1)).toMatchObject({ text: 'Link details saved', tone: 'success' })
})
it('keeps a 422 in the dialog under its field', async () => {
const { wrapper } = await open({
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
[`PUT ${PIVOT}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { note: ['The note field is required.'] } } } },
})
setNote('')
click('[data-action="save-pivot"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toBeUndefined()
expect(dialog().textContent).toContain('The note field is required.')
expect(dialog().querySelector('#pivot-members-note')!.getAttribute('aria-invalid')).toBe('true')
})
it('closes, toasts child_gone and emits gone when the link is gone', async () => {
const { wrapper } = await open({ [`GET ${PIVOT}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } })
expect(wrapper.emitted('gone')).toHaveLength(1)
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
})
it('toasts the generic error and keeps the input when the save fails otherwise', async () => {
const { wrapper } = await open({
[`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } },
[`PUT ${PIVOT}`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } },
})
setNote('Changed')
click('[data-action="save-pivot"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toBeUndefined()
expect(dialog().querySelector<HTMLInputElement>('#pivot-members-note')!.value).toBe('Changed')
expect(toasts.value.at(-1)?.tone).toBe('danger')
})
it('asks before closing with unsaved changes', async () => {
const { wrapper } = await open({ [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } } })
setNote('Changed')
await flushPromises()
click('[data-relation-pivot-modal] [data-action="cancel"]')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
click('[data-confirm-dialog] [data-action="confirm"]')
await flushPromises()
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
})