// 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 { 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 source?: typeof SOURCE | null routes?: Record } function mountWidget(options: Options = {}) { const values = ref(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; release: (reply: Reply) => void } { let release: (reply: Reply) => void = () => undefined const pending = new Promise((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(() => 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((resolve) => (release = resolve)) }) await flushPromises() const host = wrapper.find('[data-widget-host] > div').element wrapper.unmount() release([]) await flushPromises() expect(host.children).toHaveLength(0) }) })