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
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
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 { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
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`
|
||||
@@ -147,3 +148,143 @@ describe('form view', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user