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 = {}): Record { 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((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() }) })