feat(10-03): search, sort, filter, page and bulk-delete any list

- 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
This commit is contained in:
Jakub Zych
2026-09-27 16:51:00 +02:00
parent 126ca5b8ed
commit 8b5f85603f
22 changed files with 1421 additions and 118 deletions

View File

@@ -0,0 +1,349 @@
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)
})
})

View File

@@ -189,8 +189,11 @@ describe('navigation shell and list', () => {
}
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
// The checkbox column (data-select) is not a schema column.
expect(wrapper.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags', 'Created', 'Active'])
const rows = wrapper
.findAll('tbody tr')
.map((tr) => tr.findAll('td:not([data-select])').map((td) => td.text()).slice(0, 3))
expect(rows).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],