- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
the URL query through parseListQuery/toListQuery; every change is a
router replace and clears the selection; search is debounced 300 ms and
resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
asc/desc/none sorting with aria-sort, selected rows, a sticky header,
eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
delete sits in the toolbar, disabled without a selection, and confirms
with the plural deleteConfirm in a Reka alert dialog before POSTing
bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
(from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
(hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
list keys; boardwalk/dist rebuilt
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
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<string, Route> = {}): Record<string, Route> {
|
||
return {
|
||
[`GET ${LIST}/schema/list`]: { body: listSchema },
|
||
[`GET ${LIST}`]: { body: listRows },
|
||
[`GET ${LIST}/filters/maker/options`]: { body: options.maker },
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function schemaWith(patch: Record<string, unknown>) {
|
||
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)
|
||
})
|
||
})
|