Files
summercms/admin/tests/smoke/list.smoke.test.ts
Jakub Zych 9cac45c389 fix(admin): keep list state in the URL and browser storage
- listState.ts stores each list's query (search, sort, dir, page, per_page,
  filter[name]) under summer-admin.list:<controller>; an empty query is
  never stored, malformed stored values are dropped like a hand-edited URL
- ListView restores the stored query into the URL before its first list
  request when entered without list params; a URL with list params wins
- a 422 forgets the stored query; an out-of-range page clamps to last_page
- logout clears every stored list query
- check-phase10.sh allows browser storage in useSidebar.ts and listState.ts
- list smoke tests start from a clean list state
- rebuilt modules/boardwalk/dist
2026-10-09 21:04:00 +02:00

353 lines
15 KiB
TypeScript
Raw Permalink 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 { clearListStates } from '../../src/state/listState'
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()
// Lists remember their query (D-01); every test starts from a clean list.
clearListStates()
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)
})
})