Files
summercms/admin/tests/form/FormView.test.ts
Jakub Zych 11c4e5466b test(10.1-04): bring the SPA extension point under Vitest
- WidgetField: skeleton and aria-busy, the 5000 ms whenDefined timeout,
  script failure, attributes only, fill-values and locale sync, one POST
  while busy, fill-key-only patching, danger toasts, unmount
- PartialHost and partialNodes: exhaustive tag, attribute and URL
  allowlist, depth and node caps, skeleton sizes, empty, failure,
  busy refetch and ?id= only with a record
- PartialField, ListToolbar, ListView, registry, formState, FormField and
  FormView: group labels, registered toolbar actions, header refetch
  rules, form context provision and asset loading
2026-09-29 02:59:13 +02:00

291 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import { clone, extensionFormSchemaFixture, formSchemaFixture, recordFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
[`GET ${RECORD}`]: { body: recordFixture },
...overrides,
}
}
/** A form schema of plain, untabbed fields. */
function flatSchema() {
const envelope = clone(formSchemaFixture)
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
return { body: envelope }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('form view', () => {
it('is the component of the record and create routes', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.findComponent(FormView).exists()).toBe(true)
})
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.find('h1').text()).toBe('Blue widget')
expect(wrapper.find('header p').text()).toBe('Editing widget')
expect(recordCrumb.value).toBe('Blue widget')
wrapper.unmount()
expect(recordCrumb.value).toBe('')
})
it('titles a new record by the create message and offers no delete', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
expect(wrapper.find('h1').text()).toBe('New widget')
expect(wrapper.find('header p').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
expect(recordCrumb.value).toBe('New widget')
})
it('makes the form the tab panel of the active tab', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
const form = wrapper.find('form')
expect(form.attributes('role')).toBe('tabpanel')
expect(form.attributes('id')).toBe('field-panel-0')
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
})
it('has no tabs and no tab panel role for an untabbed form', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
expect(wrapper.find('form').attributes('role')).toBeUndefined()
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
})
it.each([
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
])('announces a failed load of %s and disables saving', async (_label, override) => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
expect(wrapper.find('form').exists()).toBe(false)
if (_label === 'the schema') {
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
}
})
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`PUT ${RECORD}`]: () => {
throw new TypeError('offline')
},
}),
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
})
it('toasts the server message when a delete is refused and stays on the record', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
{ attach: true },
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
})
it('keeps a create on the form when the save fails', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
)
await wrapper.find('#field-name').setValue('New one')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
})
it('follows the default redirect when the form declares no create redirect', async () => {
const envelope = clone(formSchemaFixture)
envelope.data.redirects.create.redirect = ''
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({
[`GET ${BASE}/schema/form`]: { body: envelope },
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
}),
)
await wrapper.find('#field-name').setValue('X')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
})
})
describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)', () => {
const ACTION = `${BASE}/widgets/lookup`
const SUMMARY = `${BASE}/partials/summary`
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary' }] }] }, meta: {} }
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
/** Records appended plugin scripts; each loads when the test fires it (or at once). */
function stubHead(autoload = true): HTMLScriptElement[] {
const scripts: HTMLScriptElement[] = []
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) {
scripts.push(node)
if (autoload) {
queueMicrotask(() => node.dispatchEvent(new Event('load')))
}
return node
}
if (node instanceof HTMLLinkElement) {
return node
}
return append(node)
})
return scripts
}
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record },
[`GET ${SUMMARY}`]: { body: summary },
...overrides,
}
}
function widget(root: Element): HTMLElement {
const element = root.querySelector('acme-demo-lookup')
if (!(element instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return element
}
function beforeUnloadPrevented(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
it('provides the values and the schema locale to widgets', async () => {
stubHead()
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
// The UI speaks Polish; the widget gets the schema's locale.
expect(element.getAttribute('locale')).toBe('en')
await wrapper.find('#field-name').setValue('Green widget')
expect(element.getAttribute('fill-values')).toBe('{"name":"Green widget"}')
})
it('falls back to the UI locale when the schema has none', async () => {
stubHead()
const schema = clone(extensionFormSchemaFixture)
schema.data.meta.locale = ''
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
await flushPromises()
expect(widget(wrapper.element).getAttribute('locale')).toBe('pl')
})
it('patches like an edit: the form turns dirty and the field error clears', async () => {
stubHead()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
extensionRoutes({
[`PUT ${RECORD}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Name is taken.'] } } },
},
[`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget' } }, meta: {} } },
}),
)
await flushPromises()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(wrapper.find('#field-name-error').text()).toContain('Name is taken.')
expect(beforeUnloadPrevented()).toBe(false)
widget(wrapper.element).dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
await flushPromises()
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
expect(wrapper.find('#field-name-error').exists()).toBe(false)
expect(beforeUnloadPrevented()).toBe(true)
// Nothing was saved by the action itself.
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
})
it('renders widgets and partials on create', async () => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('record-id')).toBe('')
const [partial] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(partial!).has('id')).toBe(false)
expect(wrapper.find('[data-partial-field] [data-partial="field"]').text()).toBe('Summary')
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(0)
})
it('loads the controller assets when the schema arrives and mounts the widget after its script', async () => {
const scripts = stubHead(false)
// A fresh URL: the loader remembers scripts across the tests of this file.
const fresh = '/admin-test/assets/acme/demo/js/form.js?v=1'
const schema = clone(extensionFormSchemaFixture)
schema.data.assets.scripts = [fresh]
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => (release = resolve))
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: () => pending }))
expect(scripts).toHaveLength(0)
release({ body: schema })
await flushPromises()
expect(scripts.map((script) => script.getAttribute('src'))).toEqual([fresh])
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
expect(wrapper.element.querySelector('acme-demo-lookup')).toBeNull()
scripts[0]!.dispatchEvent(new Event('load'))
await flushPromises()
expect(widget(wrapper.element).getAttribute('record-id')).toBe('1')
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
})
})