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:
349
admin/tests/smoke/list.smoke.test.ts
Normal file
349
admin/tests/smoke/list.smoke.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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', '—'],
|
||||
|
||||
Reference in New Issue
Block a user