import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { clearUser } from '../../src/state/useAuth' import { setNavigation } from '../../src/state/useNavigation' import { setSettings } from '../../src/state/useSettings' import { clearToasts } from '../../src/state/useToasts' import lang from '../fixtures/lang.json' import formSchema from '../fixtures/widgets.form-schema.json' import record from '../fixtures/widgets.record.json' import listSchema from '../fixtures/widgets.list-schema.json' import listRows from '../fixtures/widgets.list.json' import options from '../fixtures/widgets.options.json' import { API, mountApp, queryOf, requestsTo, type Route } from '../helpers' const BASE = `${API}/acme/demo/widgets` const RECORD = `${BASE}/1` const MAKER_OPTIONS = `${BASE}/fields/maker/options` const TAGS_OPTIONS = `${BASE}/fields/tags/options` function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/form`]: { body: formSchema }, [`GET ${RECORD}`]: { body: record }, [`GET ${BASE}/schema/list`]: { body: listSchema }, [`GET ${BASE}`]: { body: listRows }, [`GET ${MAKER_OPTIONS}`]: (request) => { const query = queryOf(request) if (query.get('search') === 'acme') { return { body: options.makerFieldSearch } } return { body: query.get('page') === '2' ? options.makerFieldPage2 : options.makerField } }, [`GET ${TAGS_OPTIONS}`]: { body: options.tagsField }, [`PUT ${RECORD}`]: async (request) => ({ body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta }, }), ...overrides, } } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function tabLabels(wrapper: VueWrapper): string[] { return wrapper.findAll('[role="tab"]').map((tab) => tab.text()) } async function save(wrapper: VueWrapper, action = 'save'): Promise { await wrapper.find(`[data-action="${action}"]`).trigger('click') await flushPromises() } function dialog(): HTMLElement | null { return document.body.querySelector('[data-confirm-dialog]') } async function answer(action: 'confirm' | 'cancel'): Promise { ;(dialog()!.querySelector(`[data-action="${action}"]`) as HTMLButtonElement).click() await flushPromises() } beforeEach(() => { clearUser() setNavigation([]) setSettings([]) clearToasts() setBundle(lang.data, 'pl') }) afterEach(() => { document.body.innerHTML = '' }) describe('tabs and context', () => { it('groups fields into tabs with the default tab first and filters by context', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/1', routes()) expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Members']) const [general, details, members] = wrapper.findAll('[role="tab"]') expect(general!.attributes('aria-selected')).toBe('true') expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id')) expect(wrapper.find('#field-name').exists()).toBe(true) expect(wrapper.find('#field-serial').exists()).toBe(false) await details!.trigger('click') expect(details!.attributes('aria-selected')).toBe('true') expect(wrapper.find('#field-serial').exists()).toBe(true) expect(wrapper.find('#field-batch').exists()).toBe(false) expect(wrapper.find('#field-name').exists()).toBe(false) // The relation manager has its own renderer (relation.smoke covers it). await members!.trigger('click') expect(wrapper.find('[data-relation-manager]').exists()).toBe(true) expect(wrapper.find('[data-unsupported-field]').exists()).toBe(false) }) it('shows create-context fields on create and hides update-only tabs', async () => { const { wrapper } = await mountApp('/acme/demo/widgets/create', routes()) expect(wrapper.find('h1').text()).toBe('New widget') expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details']) await wrapper.findAll('[role="tab"]')[1]!.trigger('click') expect(wrapper.find('#field-batch').exists()).toBe(true) expect(wrapper.find('#field-serial').exists()).toBe(false) expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) }) it('badges tabs holding invalid fields and switches to the first invalid field', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets/1', routes({ [`PUT ${RECORD}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'The given data was invalid.', details: { serial: ['The serial has already been taken.'] }, }, }, }, }), { attach: true }, ) await save(wrapper) const [general, details] = wrapper.findAll('[role="tab"]') expect(general!.find('[data-tab-errors]').exists()).toBe(false) expect(details!.find('[data-tab-errors]').text()).toBe('1') expect(details!.find('[data-tab-errors]').attributes('aria-label')).toBe('1 błąd') expect(details!.attributes('aria-selected')).toBe('true') expect(document.activeElement?.id).toBe('field-serial') expect(wrapper.find('#field-serial-error').text()).toBe('The serial has already been taken.') }) }) describe('toggles and relations', () => { it('renders switch and checkbox toggle cards and sends their values', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) const toggle = wrapper.find('#field-active') expect(toggle.attributes('role')).toBe('switch') expect(toggle.attributes('aria-checked')).toBe('true') expect(toggle.attributes('aria-describedby')).toBe('field-active-comment') const card = wrapper.find('[data-field="active"] [data-toggle-card]') expect(card.find('label').text()).toBe('Active') expect(card.text()).toContain('Shown in the catalogue') await toggle.trigger('click') expect(wrapper.find('#field-active').attributes('aria-checked')).toBe('false') const box = wrapper.find('#field-featured') expect((box.element as HTMLInputElement).checked).toBe(false) await box.setValue(true) await save(wrapper) const body = (await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record expect(body.active).toBe(false) expect(body.featured).toBe(true) }) it('searches a single relation with emptyOption first and sends the id or null', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) const input = wrapper.find('#field-maker') expect((input.element as HTMLInputElement).value).toBe('Bolt & Co') expect(input.attributes('role')).toBe('combobox') await input.trigger('click') await flushPromises() const first = requestsTo(calls, 'GET', MAKER_OPTIONS)[0]! expect(queryOf(first).get('page')).toBe('1') expect(queryOf(first).get('per_page')).toBe('20') expect(queryOf(first).has('search')).toBe(false) const listbox = wrapper.find('#field-maker-listbox') expect(listbox.attributes('role')).toBe('listbox') const labels = () => wrapper.findAll('#field-maker-listbox [role="option"]').map((option) => option.text()) expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co']) expect(wrapper.find('#field-maker-listbox [role="option"]').classes()).toContain('text-muted') expect(wrapper.find('[data-value="2"]').attributes('aria-selected')).toBe('true') // More results load on the next-page action. await wrapper.find('[data-action="more-options"]').trigger('mousedown') await flushPromises() expect(queryOf(requestsTo(calls, 'GET', MAKER_OPTIONS)[1]!).get('page')).toBe('2') expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd']) expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false) await input.setValue('acme') await wait(350) await flushPromises() const searched = requestsTo(calls, 'GET', MAKER_OPTIONS).at(-1)! expect(queryOf(searched).get('search')).toBe('acme') expect(queryOf(searched).get('page')).toBe('1') expect(labels()).toEqual(['— none —', 'Acme Works']) await wrapper.find('#field-maker-listbox [data-value="1"]').trigger('mousedown') expect((wrapper.find('#field-maker').element as HTMLInputElement).value).toBe('Acme Works') await save(wrapper) expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record).maker).toBe(1) await wrapper.find('#field-maker').trigger('click') await flushPromises() await wrapper.find('#field-maker-listbox [data-value="empty"]').trigger('mousedown') const field = wrapper.find('#field-maker') expect((field.element as HTMLInputElement).value).toBe('— none —') expect(field.classes()).toContain('text-muted') await save(wrapper) expect(((await requestsTo(calls, 'PUT', RECORD)[1]!.json()) as Record).maker).toBeNull() }) it('keeps multiple relation chips in order, removes and appends ids', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) const chips = () => wrapper.findAll('[data-field="tags"] [data-chip]') expect(chips().map((chip) => chip.text())).toEqual(['RRound', 'SSmall']) expect(chips()[0]!.find('[aria-hidden="true"]').text()).toBe('R') await wrapper.find('[data-field="tags"] button[aria-label="Usuń: Round"]').trigger('click') expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small']) const input = wrapper.find('#field-tags') expect(input.attributes('placeholder')).toBe('Dodaj…') await input.trigger('click') await flushPromises() // Chosen tags are not offered again. expect(wrapper.findAll('#field-tags-listbox [role="option"]').map((option) => option.text())).toEqual([ 'Round', 'Large', ]) await wrapper.find('#field-tags-listbox [data-value="4"]').trigger('mousedown') expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small', 'Large']) await save(wrapper) expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record).tags).toEqual([1, 4]) }) it('shows a read-only relation as its label only and never sends it', async () => { const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes()) const owner = wrapper.find('[data-field="owner"]') expect(owner.find('[data-relation-readonly]').text()).toBe('dana@example.test') expect(owner.find('input').exists()).toBe(false) expect(owner.find('[role="combobox"]').exists()).toBe(false) await save(wrapper) expect(requestsTo(calls, 'GET', `${BASE}/fields/owner/options`)).toHaveLength(0) expect((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record).not.toHaveProperty('owner') }) }) describe('footer and lifecycle', () => { it('confirms delete with the form message, deletes and returns to the list', async () => { const { wrapper, router, calls } = await mountApp( '/acme/demo/widgets/1', routes({ [`DELETE ${RECORD}`]: { body: { data: { deleted: 1 }, meta: {} } } }), ) await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() expect(dialog()!.textContent).toContain('Delete this widget?') await answer('cancel') expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(0) await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() await answer('confirm') const [del] = requestsTo(calls, 'DELETE', RECORD) expect(del!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') expect(wrapper.find('[role="status"]').text()).toContain('Widget deleted') }) it('asks before leaving a dirty form and guards the page unload', async () => { const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes()) const clean = new Event('beforeunload', { cancelable: true }) window.dispatchEvent(clean) expect(clean.defaultPrevented).toBe(false) await wrapper.find('#field-name').setValue('Changed') const unload = new Event('beforeunload', { cancelable: true }) window.dispatchEvent(unload) expect(unload.defaultPrevented).toBe(true) await wrapper.find('[data-action="cancel"]').trigger('click') await flushPromises() expect(dialog()!.textContent).toContain('Masz niezapisane zmiany. Porzucić je?') await answer('cancel') expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1') await wrapper.find('[data-action="back"]').trigger('click') await flushPromises() expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do listy') await answer('confirm') expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') }) it('leaves a clean form without asking', async () => { const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes()) await wrapper.find('[data-action="cancel"]').trigger('click') await flushPromises() expect(dialog()).toBeNull() expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') }) it('creates a record and follows the mapped create redirect', async () => { const { wrapper, router, calls } = await mountApp( '/acme/demo/widgets/create', routes({ [`POST ${BASE}`]: async (request) => ({ status: 201, body: { data: { id: 9, ...((await request.json()) as object) }, meta: { labels: {} } }, }), [`GET ${BASE}/9`]: { body: { data: { ...record.data, id: 9, name: 'Fresh widget' }, meta: record.meta } }, }), ) expect((wrapper.find('#field-active').attributes('aria-checked'))).toBe('false') await wrapper.find('#field-name').setValue('Fresh widget') await save(wrapper) const [post] = requestsTo(calls, 'POST', BASE) const body = (await post!.json()) as Record expect(body).toMatchObject({ name: 'Fresh widget', active: false, featured: false, tags: [] }) expect(body).not.toHaveProperty('serial') expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/9') expect(wrapper.find('[role="status"]').text()).toContain('Widget „Fresh widget” saved') expect(wrapper.find('h1').text()).toBe('Fresh widget') }) it('saves and closes to the mapped redirectClose with the toast on the list', async () => { const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes()) await wrapper.find('#field-name').setValue('Closed widget') await save(wrapper, 'save-close') expect(dialog()).toBeNull() expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets') expect(wrapper.find('h1').text()).toBe('Widgets') expect(wrapper.find('[role="status"]').text()).toContain('Widget „Closed widget” saved') }) })