From 11c4e5466b346c99e4343340dba310574f767402 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 29 Sep 2026 02:59:13 +0200 Subject: [PATCH] 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 --- admin/tests/form/FormField.test.ts | 42 ++- admin/tests/form/FormView.test.ts | 147 ++++++++++- admin/tests/form/PartialField.test.ts | 93 +++++++ admin/tests/form/WidgetField.test.ts | 360 ++++++++++++++++++++++++++ admin/tests/form/formState.test.ts | 9 +- admin/tests/form/registry.test.ts | 23 ++ admin/tests/list/ListToolbar.test.ts | 53 ++++ admin/tests/list/ListView.test.ts | 174 ++++++++++++- admin/tests/list/PartialHost.test.ts | 302 +++++++++++++++++++++ 9 files changed, 1194 insertions(+), 9 deletions(-) create mode 100644 admin/tests/form/PartialField.test.ts create mode 100644 admin/tests/form/WidgetField.test.ts create mode 100644 admin/tests/list/PartialHost.test.ts diff --git a/admin/tests/form/FormField.test.ts b/admin/tests/form/FormField.test.ts index 5c36a06..7d25022 100644 --- a/admin/tests/form/FormField.test.ts +++ b/admin/tests/form/FormField.test.ts @@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils' import FieldRenderer from '../../src/components/form/FieldRenderer.vue' import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue' import FormField from '../../src/components/form/FormField.vue' -import { formField } from '../fixtures/typed' -import { resetState } from '../helpers' +import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed' +import { mockApi, resetState } from '../helpers' beforeEach(() => { resetState() @@ -55,6 +55,44 @@ describe('form field', () => { }) }) +describe('widget and partial rows (Phase 10.1)', () => { + function extensionField(name: string, change: Record = {}) { + const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === name) + if (!field) { + throw new Error(`fixture field ${name} missing`) + } + return { ...field, ...change } + } + + it.each(['lookup', 'summary'])('labels the %s group with a span the group points at', (name) => { + mockApi({}) + const wrapper = mount(FormField, { + props: { field: extensionField(name, { required: true }), modelValue: undefined, idPrefix: 'f', source: null }, + }) + const label = wrapper.find(`#f-${name}-label`) + expect(label.element.tagName).toBe('SPAN') + expect(label.classes()).toContain('font-semibold') + expect(label.find('[aria-hidden="true"]').text()).toBe('*') + expect(wrapper.find('label').exists()).toBe(false) + expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBe(`f-${name}-label`) + }) + + it.each(['lookup', 'summary'])('shows no label row for an unlabelled %s', (name) => { + mockApi({}) + const wrapper = mount(FormField, { props: { field: extensionField(name, { label: '' }), modelValue: undefined, idPrefix: 'f', source: null } }) + expect(wrapper.find(`#f-${name}-label`).exists()).toBe(false) + expect(wrapper.find('label').exists()).toBe(false) + expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBeUndefined() + }) + + it('points the widget group at its comment', () => { + mockApi({}) + const wrapper = mount(FormField, { props: { field: extensionField('lookup'), modelValue: undefined, idPrefix: 'f', source: null } }) + expect(wrapper.find('#f-lookup-comment').text()).toBe('Fills in the name. Save to keep it.') + expect(wrapper.find('#f-lookup').attributes('aria-describedby')).toBe('f-lookup-comment') + }) +}) + describe('field renderer', () => { it('picks the control by type and forwards the shared props and updates', async () => { const wrapper = mount(FieldRenderer, { diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index 8e92f53..1800941 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -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((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 = {}): Record { + 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((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() + }) +}) diff --git a/admin/tests/form/PartialField.test.ts b/admin/tests/form/PartialField.test.ts new file mode 100644 index 0000000..ed3f42a --- /dev/null +++ b/admin/tests/form/PartialField.test.ts @@ -0,0 +1,93 @@ +// PartialField (D-09; UI-SPEC S2): a `type: partial` form row renders its +// controller partial on create (no id) and update (?id=) inside a group named +// by the row's label span; without a label there is no label row and no +// aria-labelledby. Mounted through FormField, as FormGrid renders it. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import FormField from '../../src/components/form/FormField.vue' +import PartialField from '../../src/components/form/fields/PartialField.vue' +import PartialHost from '../../src/components/partial/PartialHost.vue' +import type { FormField as Field } from '../../src/api/types' +import { clone, extensionFormSchemaFixture } from '../fixtures/typed' +import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers' + +const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } +const SUMMARY = `${API}/acme/demo/widgets/partials/summary` +const reply = { body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} } } + +function summaryField(change: Partial = {}): Field { + const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'summary') + if (!field) { + throw new Error('fixture summary field missing') + } + return { ...field, ...change } +} + +function mountRow(field: Field, recordId: number | null, source: typeof SOURCE | null = SOURCE) { + return mount(FormField, { props: { field, modelValue: undefined, source, recordId, idPrefix: 'f' } }) +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('partial form row', () => { + it('fetches without an id on create and with the record id on update', async () => { + const calls = mockApi({ [`GET ${SUMMARY}`]: reply }) + mountRow(summaryField(), null) + mountRow(summaryField(), 3) + await flushPromises() + const [create, update] = requestsTo(calls, 'GET', SUMMARY) + expect(queryOf(create!).has('id')).toBe(false) + expect(queryOf(update!).get('id')).toBe('3') + }) + + it('is a group named by the row label span, not a