import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { flushPromises } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { parseListQuery, toListQuery } from '../../src/app/listQuery' import { clearUser } from '../../src/state/useAuth' import { setNavigation } from '../../src/state/useNavigation' import { clearToasts } from '../../src/state/useToasts' import lang from '../fixtures/lang.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 LIST = `${API}/acme/demo/widgets` function routes(overrides: Record = {}): Record { return { [`GET ${LIST}/schema/list`]: { body: listSchema }, [`GET ${LIST}`]: { body: listRows }, [`GET ${LIST}/filters/maker/options`]: { body: options.maker }, ...overrides, } } function schemaWith(patch: Record) { return { ...listSchema, data: { ...listSchema.data, ...patch } } } function listGets(calls: Request[]): Request[] { return requestsTo(calls, 'GET', LIST) } function lastListQuery(calls: Request[]): URLSearchParams { const gets = listGets(calls) return queryOf(gets[gets.length - 1]!) } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) beforeEach(() => { clearUser() setNavigation([]) clearToasts() setBundle(lang.data, 'pl') }) afterEach(() => { document.body.innerHTML = '' }) describe('list query', () => { it('round-trips search, sort, paging and filters and drops malformed values', () => { const parsed = parseListQuery({ search: ' blue ', sort: 'name', dir: 'sideways', page: '0', per_page: 'x', 'filter[active]': 'true', 'filter[created]': '', }) expect(parsed).toEqual({ search: 'blue', sort: 'name', dir: 'asc', filter: { active: 'true' } }) expect(toListQuery({ ...parsed, page: 3, per_page: 50 })).toEqual({ search: 'blue', sort: 'name', dir: 'asc', page: '3', per_page: '50', 'filter[active]': 'true', }) expect(toListQuery({ page: 1 })).toEqual({}) }) }) describe('list screen', () => { it('renders the heading, toolbar buttons in their places and typed cells', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes()) expect(wrapper.find('h1').text()).toBe('Widgets') expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets') const create = wrapper.find('header [data-action="create"]') expect(create.text()).toBe('New widget') expect(create.attributes('href')).toBe('/acme/demo/widgets/create') expect(wrapper.find('[data-list-search]').attributes('placeholder')).toBe('Search widgets…') const del = wrapper.find('[data-action="delete"]') expect(del.text()).toBe('Delete selected') expect(del.attributes('disabled')).toBeDefined() const [blue, green] = wrapper.findAll('tbody tr') const blueCells = blue!.findAll('td:not([data-select])') expect(blueCells[3]!.text()).toBe('2026-09-21 14:32') expect(blueCells[3]!.find('span').classes()).toContain('tabular-nums') expect(blueCells[4]!.find('[data-switch="true"]').text()).toBe('Tak') expect(blueCells[4]!.find('[data-switch="true"] svg').exists()).toBe(true) const greenCells = green!.findAll('td:not([data-select])') expect(greenCells[2]!.find('[data-empty]').text()).toBe('—') expect(greenCells[3]!.find('[data-empty]').exists()).toBe(true) expect(greenCells[4]!.find('[data-switch="false"]').text()).toBe('Nie') }) it('cycles sorting asc, desc, none with aria-sort and the URL', async () => { const { wrapper, router, calls } = await mountApp('/acme/demo/widgets', routes()) const nameHeader = () => wrapper.findAll('th:not([data-select])')[0]! expect(nameHeader().attributes('aria-sort')).toBe('none') expect(wrapper.findAll('th:not([data-select])')[2]!.attributes('aria-sort')).toBeUndefined() await wrapper.find('[data-sort="name"]').trigger('click') await flushPromises() expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'asc' }) expect(nameHeader().attributes('aria-sort')).toBe('ascending') expect(nameHeader().classes()).toContain('text-text') expect(lastListQuery(calls).get('sort')).toBe('name') expect(lastListQuery(calls).get('dir')).toBe('asc') await wrapper.find('[data-sort="name"]').trigger('click') await flushPromises() expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'desc' }) expect(nameHeader().attributes('aria-sort')).toBe('descending') await wrapper.find('[data-sort="name"]').trigger('click') await flushPromises() expect(router.currentRoute.value.query.sort).toBeUndefined() expect(nameHeader().attributes('aria-sort')).toBe('none') expect(lastListQuery(calls).has('sort')).toBe(false) }) it('debounces search by 300 ms and resets the page', async () => { const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=3', routes()) expect(lastListQuery(calls).get('page')).toBe('3') await wrapper.find('[data-list-search]').setValue('blue') await wait(100) expect(router.currentRoute.value.query.search).toBeUndefined() await wait(300) await flushPromises() expect(router.currentRoute.value.query).toEqual({ search: 'blue' }) expect(lastListQuery(calls).get('search')).toBe('blue') expect(lastListQuery(calls).has('page')).toBe(false) }) it('selects the page with a tri-state header and clears the selection on change', async () => { const { wrapper } = await mountApp('/acme/demo/widgets', routes()) const header = () => wrapper.find('th[data-select] [role="checkbox"]') const rowBoxes = () => wrapper.findAll('td[data-select] [role="checkbox"]') expect(header().attributes('aria-checked')).toBe('false') await rowBoxes()[0]!.trigger('click') expect(header().attributes('aria-checked')).toBe('mixed') expect(header().find('svg').exists()).toBe(true) expect(wrapper.findAll('tbody tr')[0]!.classes()).toContain('bg-sel') expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 1') await header().trigger('click') expect(header().attributes('aria-checked')).toBe('true') expect(rowBoxes().map((box) => box.attributes('aria-checked'))).toEqual(['true', 'true']) await header().trigger('click') expect(header().attributes('aria-checked')).toBe('false') await rowBoxes()[1]!.trigger('click') await wrapper.find('[data-sort="code"]').trigger('click') await flushPromises() expect(header().attributes('aria-checked')).toBe('false') expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) }) it('confirms bulk delete with the plural message and posts the ids', async () => { const { wrapper, calls } = await mountApp( '/acme/demo/widgets', routes({ [`POST ${LIST}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } } }), ) const gets = listGets(calls).length await wrapper.find('th[data-select] [role="checkbox"]').trigger('click') const del = wrapper.find('[data-action="delete"]') expect(del.attributes('disabled')).toBeUndefined() expect(del.classes()).toContain('text-danger') await del.trigger('click') await flushPromises() const dialog = document.body.querySelector('[data-confirm-dialog]')! expect(dialog.getAttribute('role')).toBe('alertdialog') expect(dialog.textContent).toContain('Delete 2 widgets?') ;(dialog.querySelector('[data-action="confirm"]') as HTMLButtonElement).click() await flushPromises() const [post] = requestsTo(calls, 'POST', `${LIST}/bulk-delete`) expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(await post!.json()).toEqual({ ids: [1, 2] }) expect(wrapper.find('[role="status"]').text()).toContain('Deleted 2 widgets') expect(listGets(calls).length).toBe(gets + 1) expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) }) it('sends nothing when the delete confirmation is cancelled and toasts a 409', async () => { const { wrapper, calls } = await mountApp( '/acme/demo/widgets', routes({ [`POST ${LIST}/bulk-delete`]: { status: 409, body: { error: { code: 'conflict', message: 'Widgets are still in use.', details: {} } }, }, }), ) await wrapper.findAll('td[data-select] [role="checkbox"]')[0]!.trigger('click') await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete 1 widget?') ;(document.body.querySelector('[data-confirm-dialog] [data-action="cancel"]') as HTMLButtonElement).click() await flushPromises() expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(0) await wrapper.find('[data-action="delete"]').trigger('click') await flushPromises() ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click() await flushPromises() expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(1) const toast = wrapper.find('[data-tone="danger"]') expect(toast.text()).toContain('Widgets are still in use.') }) it('writes each filter shape to filter[name] and resets the page', async () => { const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=2', routes()) const bar = wrapper.find('[data-filter-bar]') expect(bar.exists()).toBe(true) const active = bar.find('select[data-filter="active"]') expect(active.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Tak', 'Nie']) await active.setValue('true') await flushPromises() expect(router.currentRoute.value.query).toEqual({ 'filter[active]': 'true' }) expect(lastListQuery(calls).get('filter[active]')).toBe('true') await bar.find('[data-filter="created-from"]').setValue('2026-09-01') await bar.find('[data-filter="created-from"]').trigger('change') expect(router.currentRoute.value.query['filter[created]']).toBeUndefined() await bar.find('[data-filter="created-to"]').setValue('2026-09-30') await bar.find('[data-filter="created-to"]').trigger('change') await flushPromises() expect(router.currentRoute.value.query['filter[created]']).toBe('2026-09-01..2026-09-30') const maker = wrapper.find('select[data-filter="maker"]') expect(requestsTo(calls, 'GET', `${LIST}/filters/maker/options`)).toHaveLength(1) expect(maker.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Acme Works', 'Bolt & Co']) await maker.setValue('2') await flushPromises() expect(router.currentRoute.value.query).toEqual({ 'filter[active]': 'true', 'filter[created]': '2026-09-01..2026-09-30', 'filter[maker]': '2', }) const sent = lastListQuery(calls) expect(sent.get('filter[maker]')).toBe('2') expect(sent.get('filter[created]')).toBe('2026-09-01..2026-09-30') await wrapper.find('select[data-filter="active"]').setValue('') await flushPromises() expect(router.currentRoute.value.query['filter[active]']).toBeUndefined() }) it('hides the filter bar without filters and the per-page select with one option', async () => { const { wrapper, router } = await mountApp( '/acme/demo/widgets', routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith({ filters: [] }) } }), ) expect(wrapper.find('[data-filter-bar]').exists()).toBe(false) expect(wrapper.find('[data-per-page]').exists()).toBe(false) expect(wrapper.find('[data-range]').text()).toBe('1–2 z 2') expect(wrapper.find('[aria-current="page"][data-page="1"]').exists()).toBe(true) expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined() expect(router.currentRoute.value.query).toEqual({}) }) it('offers the schema per-page options and pages with ellipsis', async () => { const { wrapper, router, calls } = await mountApp( '/acme/demo/widgets?page=5', routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith({ perPageOptions: [10, 20, 50] }) }, [`GET ${LIST}`]: { body: { ...listRows, meta: { page: 5, per_page: 20, total: 400, last_page: 20 } } }, }), ) const perPage = wrapper.find('[data-per-page]') expect(perPage.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50']) expect(wrapper.find('[data-range]').text()).toBe('81–100 z 400') const pager = wrapper.findAll('nav [data-page]').map((button) => button.attributes('data-page')) expect(pager).toEqual(['prev', '1', '4', '5', '6', '20', 'next']) expect(wrapper.find('[data-page="5"]').attributes('aria-current')).toBe('page') await wrapper.find('[data-page="6"]').trigger('click') await flushPromises() expect(router.currentRoute.value.query).toEqual({ page: '6' }) await perPage.setValue('50') await flushPromises() expect(router.currentRoute.value.query).toEqual({ per_page: '50' }) expect(lastListQuery(calls).get('per_page')).toBe('50') }) it('shows the empty message and the plural zero results without a pager', async () => { const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }), ) expect(wrapper.find('[data-empty-list]').text()).toBe('No widgets yet.') expect(wrapper.find('[data-range]').text()).toBe('0 wyników') expect(wrapper.find('nav [data-page]').exists()).toBe(false) }) it('shows the empty-search state with the term and clears the search', async () => { const { wrapper, router } = await mountApp( '/acme/demo/widgets?search=zzz', routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }), ) const state = wrapper.find('[data-empty-search]') expect(state.text()).toContain('Nothing found') expect(state.text()).toContain('No widget matches “zzz”.') expect(state.find('svg').exists()).toBe(true) expect((wrapper.find('[data-list-search]').element as HTMLInputElement).value).toBe('zzz') await state.find('[data-action="clear-search"]').trigger('click') await flushPromises() expect(router.currentRoute.value.query).toEqual({}) }) it('shows eight skeleton rows while loading and keeps the toolbar and footer', async () => { let release: (() => void) | undefined const { wrapper } = await mountApp( '/acme/demo/widgets', routes({ [`GET ${LIST}`]: () => new Promise((resolve) => { release = () => resolve({ body: listRows }) }), }), ) expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8) expect(wrapper.find('[data-list-search]').exists()).toBe(true) expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…') expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true') release?.() await flushPromises() expect(wrapper.findAll('[data-skeleton]')).toHaveLength(0) expect(wrapper.findAll('tbody tr')).toHaveLength(2) }) })