Files
summercms/admin/tests/smoke/list.smoke.test.ts
Jakub Zych 8b5f85603f 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
2026-09-27 16:51:00 +02:00

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})