- 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
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
// WidgetField (D-04, D-05, D-07, D-08; UI-SPEC S3, S5; T-10.1-17, T-10.1-18):
|
|
// the plugin custom element is created once its controller script is in and
|
|
// its tag is defined, gets attributes only, and asks for its action with a
|
|
// bubbling summer-action event the SPA posts. Mounted on its own with the
|
|
// form context FormView would provide; HTTP goes through the fetch mock.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { ref } from 'vue'
|
|
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
|
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
|
|
import {
|
|
FORM_ASSETS,
|
|
FORM_LOCALE,
|
|
FORM_PATCH,
|
|
FORM_VALUES,
|
|
WIDGET_EVENT,
|
|
WIDGET_TIMEOUT,
|
|
} from '../../src/components/form/formContext'
|
|
import type { AdminRecord, FormField } from '../../src/api/types'
|
|
import { toasts } from '../../src/state/useToasts'
|
|
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
|
|
import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
|
|
|
const ACTION = `${API}/acme/demo/widgets/widgets/lookup`
|
|
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
|
const WIDGET_FAILED = 'Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.'
|
|
const ACTION_FAILED = 'Nie udało się wykonać akcji. Spróbuj ponownie.'
|
|
|
|
class AcmeDemoLookup extends HTMLElement {}
|
|
if (!customElements.get('acme-demo-lookup')) {
|
|
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
|
}
|
|
|
|
/** The fixture's lookup widget, filling name and color. */
|
|
function lookupField(change: Partial<FormField> = {}): FormField {
|
|
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'lookup')
|
|
if (!field) {
|
|
throw new Error('fixture lookup field missing')
|
|
}
|
|
return { ...field, fill: ['name', 'color'], ...change }
|
|
}
|
|
|
|
interface Options {
|
|
field?: FormField
|
|
recordId?: number | null
|
|
values?: AdminRecord
|
|
locale?: string
|
|
assets?: () => Promise<string[]>
|
|
source?: typeof SOURCE | null
|
|
routes?: Record<string, Route>
|
|
}
|
|
|
|
function mountWidget(options: Options = {}) {
|
|
const values = ref<AdminRecord>(options.values ?? { name: 'Blue widget', color: '#0000ff', weight: 3 })
|
|
const locale = ref(options.locale ?? 'en')
|
|
const patch = vi.fn((name: string, value: unknown) => {
|
|
values.value = { ...values.value, [name]: value }
|
|
})
|
|
const calls = mockApi(options.routes ?? {})
|
|
const wrapper = mount(WidgetField, {
|
|
props: {
|
|
field: options.field ?? lookupField(),
|
|
modelValue: undefined,
|
|
controlId: 'f-lookup',
|
|
describedBy: 'f-lookup-comment',
|
|
source: options.source === undefined ? SOURCE : options.source,
|
|
recordId: options.recordId === undefined ? 7 : options.recordId,
|
|
},
|
|
global: {
|
|
provide: {
|
|
[FORM_VALUES as symbol]: values,
|
|
[FORM_PATCH as symbol]: patch,
|
|
[FORM_LOCALE as symbol]: locale,
|
|
[FORM_ASSETS as symbol]: options.assets ?? (() => Promise.resolve([])),
|
|
},
|
|
},
|
|
})
|
|
return { wrapper, values, locale, patch, calls }
|
|
}
|
|
|
|
function element(wrapper: VueWrapper): HTMLElement {
|
|
const found = wrapper.find('[data-widget-host] > div > *').element
|
|
if (!(found instanceof HTMLElement)) {
|
|
throw new Error('widget element not mounted')
|
|
}
|
|
return found
|
|
}
|
|
|
|
function act(target: HTMLElement): void {
|
|
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
|
}
|
|
|
|
/** A route whose reply the test releases. */
|
|
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
|
|
let release: (reply: Reply) => void = () => undefined
|
|
const pending = new Promise<Reply>((resolve) => {
|
|
release = resolve
|
|
})
|
|
return { route: () => pending, release }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('loading and failure states', () => {
|
|
it('shows a skeleton and aria-busy while the controller script loads', async () => {
|
|
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>(() => undefined) })
|
|
await flushPromises()
|
|
const host = wrapper.find('[data-widget-host]')
|
|
expect(host.attributes('role')).toBe('group')
|
|
expect(host.attributes('id')).toBe('f-lookup')
|
|
expect(host.attributes('aria-busy')).toBe('true')
|
|
expect(host.attributes('aria-labelledby')).toBe('f-lookup-label')
|
|
expect(host.attributes('aria-describedby')).toBe('f-lookup-comment')
|
|
const skeleton = wrapper.find('[data-widget-skeleton]')
|
|
expect(skeleton.attributes('aria-hidden')).toBe('true')
|
|
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[42px]', 'w-[160px]', 'rounded-control', 'bg-skel']))
|
|
expect(wrapper.find('acme-demo-lookup').exists()).toBe(false)
|
|
})
|
|
|
|
it('waits for the tag to be defined and gives up after 5000 ms', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-never' }) })
|
|
await flushPromises()
|
|
expect(WIDGET_TIMEOUT).toBe(5000)
|
|
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT - 1)
|
|
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
|
|
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
|
|
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
await flushPromises()
|
|
const box = wrapper.find('[data-widget-failed]')
|
|
expect(box.attributes('role')).toBe('alert')
|
|
expect(box.attributes('id')).toBe('f-lookup')
|
|
expect(box.attributes('aria-describedby')).toBe('f-lookup-comment')
|
|
expect(box.text()).toBe(WIDGET_FAILED)
|
|
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
|
|
expect(wrapper.find('[data-widget-host]').exists()).toBe(false)
|
|
})
|
|
|
|
it('mounts an element defined while it waits (customElements.whenDefined)', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-late' }) })
|
|
await flushPromises()
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
customElements.define('acme-demo-late', class extends HTMLElement {})
|
|
await flushPromises()
|
|
expect(wrapper.find('acme-demo-late').exists()).toBe(true)
|
|
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
|
|
// The timeout was cancelled: nothing fails later.
|
|
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT)
|
|
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
|
|
})
|
|
|
|
it('fails at once when the script failed and the tag is still undefined', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
const { wrapper } = mountWidget({
|
|
field: lookupField({ widget: 'acme-demo-broken' }),
|
|
assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/broken.js']),
|
|
})
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-widget-failed]').attributes('role')).toBe('alert')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('still mounts a tag another script already defined when one script failed', async () => {
|
|
const { wrapper } = mountWidget({ assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/other.js']) })
|
|
await flushPromises()
|
|
expect(wrapper.find('acme-demo-lookup').exists()).toBe(true)
|
|
})
|
|
|
|
it('fails without a tag or without a controller', async () => {
|
|
for (const options of [{ field: lookupField({ widget: '' }) }, { source: null }] as Options[]) {
|
|
const { wrapper } = mountWidget(options)
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-widget-failed]').exists()).toBe(true)
|
|
wrapper.unmount()
|
|
}
|
|
})
|
|
|
|
it('has no aria-labelledby without a label', async () => {
|
|
const { wrapper } = mountWidget({ field: lookupField({ label: undefined }) })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-widget-host]').attributes('aria-labelledby')).toBeUndefined()
|
|
expect(element(wrapper).getAttribute('label')).toBe('Look up')
|
|
})
|
|
})
|
|
|
|
describe('element attributes (D-08)', () => {
|
|
it('sets attributes only, never properties or functions', async () => {
|
|
const { wrapper } = mountWidget()
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
expect(target.tagName.toLowerCase()).toBe('acme-demo-lookup')
|
|
expect(target.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
|
|
expect(target.getAttribute('record-id')).toBe('7')
|
|
expect(target.getAttribute('field-name')).toBe('lookup')
|
|
expect(target.getAttribute('locale')).toBe('en')
|
|
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget","color":"#0000ff"}')
|
|
expect(target.getAttribute('label')).toBe('Look up')
|
|
expect(target.getAttribute('busy-label')).toBe('Wczytywanie…')
|
|
expect(Object.keys(target)).toEqual([])
|
|
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
|
|
})
|
|
|
|
it('follows form values and the locale, and leaves out absent fill values', async () => {
|
|
const { wrapper, values, locale } = mountWidget({ values: { name: 'Blue widget' } })
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
|
values.value = { name: 'Red widget', color: '#ff0000', weight: 9 }
|
|
await flushPromises()
|
|
expect(target.getAttribute('fill-values')).toBe('{"name":"Red widget","color":"#ff0000"}')
|
|
locale.value = 'pl'
|
|
await flushPromises()
|
|
expect(target.getAttribute('locale')).toBe('pl')
|
|
})
|
|
|
|
it('labels the element from actionLabel, then label, then the field name', async () => {
|
|
const { wrapper } = mountWidget({ field: lookupField({ actionLabel: undefined, label: undefined }) })
|
|
await flushPromises()
|
|
expect(element(wrapper).getAttribute('label')).toBe('lookup')
|
|
})
|
|
|
|
it('has an empty record id on create', async () => {
|
|
const { wrapper } = mountWidget({ recordId: null, values: {} })
|
|
await flushPromises()
|
|
expect(element(wrapper).getAttribute('record-id')).toBe('')
|
|
expect(element(wrapper).getAttribute('fill-values')).toBe('{}')
|
|
})
|
|
})
|
|
|
|
describe('summer-action (D-05, D-07)', () => {
|
|
it('posts {record_id, values} with the CSRF header, once while busy', async () => {
|
|
const action = deferred()
|
|
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: action.route } })
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
act(target)
|
|
await flushPromises()
|
|
expect(target.hasAttribute('busy')).toBe(true)
|
|
act(target)
|
|
act(target)
|
|
await flushPromises()
|
|
const posts = requestsTo(calls, 'POST', ACTION)
|
|
expect(posts).toHaveLength(1)
|
|
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
|
expect(await posts[0]!.json()).toEqual({ record_id: 7, values: { name: 'Blue widget', color: '#0000ff' } })
|
|
|
|
action.release({ body: { data: { message: '', fill: {} }, meta: {} } })
|
|
await flushPromises()
|
|
expect(target.hasAttribute('busy')).toBe(false)
|
|
act(target)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(2)
|
|
})
|
|
|
|
it('patches only the declared fill keys the server returned and toasts the message', async () => {
|
|
const { wrapper, patch } = mountWidget({
|
|
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', weight: 99 } }, meta: {} } } },
|
|
})
|
|
await flushPromises()
|
|
act(element(wrapper))
|
|
await flushPromises()
|
|
expect(patch.mock.calls).toEqual([['name', 'Catalogue widget']])
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Name filled in', tone: 'success' })
|
|
expect(element(wrapper).getAttribute('fill-values')).toBe('{"name":"Catalogue widget","color":"#0000ff"}')
|
|
expect(element(wrapper).hasAttribute('state')).toBe(false)
|
|
})
|
|
|
|
it('does not toast an empty message', async () => {
|
|
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: { body: { data: { message: '', fill: { color: null } }, meta: {} } } } })
|
|
await flushPromises()
|
|
act(element(wrapper))
|
|
await flushPromises()
|
|
expect(patch.mock.calls).toEqual([['color', null]])
|
|
expect(toasts.value).toHaveLength(0)
|
|
})
|
|
|
|
it('sends no record_id on create', async () => {
|
|
const { wrapper, calls } = mountWidget({
|
|
recordId: null,
|
|
values: {},
|
|
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Done', fill: {} }, meta: {} } } },
|
|
})
|
|
await flushPromises()
|
|
act(element(wrapper))
|
|
await flushPromises()
|
|
expect(await requestsTo(calls, 'POST', ACTION)[0]!.json()).toEqual({ values: {} })
|
|
})
|
|
|
|
it.each([
|
|
['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } } }, 'Catalogue unreachable.'],
|
|
['the framework text without one', { status: 500, body: {} }, ACTION_FAILED],
|
|
['the framework text on a 403', { status: 403, body: { error: { code: 'forbidden', message: '', details: {} } } }, ACTION_FAILED],
|
|
])('toasts danger with %s and marks the element', async (_, reply, text) => {
|
|
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: reply } })
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
act(target)
|
|
await flushPromises()
|
|
expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' })
|
|
expect(target.getAttribute('state')).toBe('error')
|
|
expect(target.hasAttribute('busy')).toBe(false)
|
|
expect(patch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('clears the error state on the next run', async () => {
|
|
let reply: Reply = { status: 500, body: {} }
|
|
const { wrapper } = mountWidget({ routes: { [`POST ${ACTION}`]: () => reply } })
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
act(target)
|
|
await flushPromises()
|
|
expect(target.getAttribute('state')).toBe('error')
|
|
reply = { body: { data: { message: 'ok', fill: {} }, meta: {} } }
|
|
act(target)
|
|
await flushPromises()
|
|
expect(target.hasAttribute('state')).toBe(false)
|
|
})
|
|
|
|
it('toasts the framework text when the request cannot be sent', async () => {
|
|
const { wrapper } = mountWidget()
|
|
await flushPromises()
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
|
const target = element(wrapper)
|
|
act(target)
|
|
await flushPromises()
|
|
expect(toasts.value[0]).toMatchObject({ text: ACTION_FAILED, tone: 'danger' })
|
|
expect(target.getAttribute('state')).toBe('error')
|
|
expect(target.hasAttribute('busy')).toBe(false)
|
|
})
|
|
|
|
it('stops listening once unmounted', async () => {
|
|
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: (): Reply => ({ body: { data: { message: 'x', fill: {} }, meta: {} } }) } })
|
|
await flushPromises()
|
|
const target = element(wrapper)
|
|
wrapper.unmount()
|
|
act(target)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(0)
|
|
})
|
|
|
|
it('never mounts an element after an unmount during loading', async () => {
|
|
let release: (failed: string[]) => void = () => undefined
|
|
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>((resolve) => (release = resolve)) })
|
|
await flushPromises()
|
|
const host = wrapper.find('[data-widget-host] > div').element
|
|
wrapper.unmount()
|
|
release([])
|
|
await flushPromises()
|
|
expect(host.children).toHaveLength(0)
|
|
})
|
|
})
|