Files
summercms/admin/tests/list/ListView.test.ts
Jakub Zych 9f5e807af6 test(16.2-06): admin SPA coverage of plugin field types and View site
- fieldTypes: one load per script URL, failed and refused scripts
  reported, global styles enabled on every screen, empty list on failure
- registry, formState, FormField and ListView: plugin renderers, locale
  maps, notices as literal text, unknown column types as text
- AppShell: the View site link's href, target, rel and en/pl label
2026-10-10 16:03:07 +02:00

834 lines
37 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { LIST_STATE_PREFIX, readListState, setListStateOwner, writeListState } from '../../src/state/listState'
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import {
clone,
extensionListSchemaFixture,
extensionPartialFixture,
langFixture,
listFixture,
listSchemaFixture,
rosterListFixture,
rosterListSchemaFixture,
} from '../fixtures/typed'
import { API, mountApp, profile, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
[`GET ${BASE}`]: { body: listFixture },
...overrides,
}
}
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
const envelope = clone(listSchemaFixture)
change(envelope.data)
return { body: envelope }
}
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
await wrapper.find('th [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('list view', () => {
it('is the component of the list route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.findComponent(ListView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
})
it('sends the URL query to the list endpoint', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
const [request] = requestsTo(calls, 'GET', BASE)
const query = queryOf(request!)
expect(query.get('search')).toBe('blue')
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('page')).toBe('2')
expect(query.get('filter[active]')).toBe('true')
})
it('announces a failed list load as an alert and keeps the toolbar', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
})
it('announces a failed schema load and falls back to the navigation label', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets', {
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
})
expect(wrapper.find('h1').text()).toBe('Gadgets')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('shows the create button only when the toolbar declares it', async () => {
const withCreate = await mountApp('/acme/demo/widgets', routes())
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
withCreate.wrapper.unmount()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
)
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
delete schema.recordUrl
schema.showCheckboxes = false
schema.showSorting = false
}),
}),
)
expect(wrapper.find('tbody a').exists()).toBe(false)
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
expect(wrapper.find('[data-sort]').exists()).toBe(false)
})
it('toasts the server message when a bulk delete is refused', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
})
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`POST ${BASE}/bulk-delete`]: () => {
throw new TypeError('offline')
},
}),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
})
})
describe('plugin field type columns (Phase 16.2)', () => {
it('renders the row value of a column type it does not know as plain text', async () => {
const schema = schemaWith((data) => {
const code = data.columns.find((column) => column.key === 'code')!
;(code as { type?: string }).type = 'blogeditor'
})
const rows = clone(listFixture)
;(rows.data[0] as Record<string, unknown>).code = '<b>Grzyby</b> rosną'
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}/schema/list`]: schema, [`GET ${BASE}`]: { body: rows } }))
const cell = wrapper.findAll('tbody tr')[0]!.findAll('td').find((td) => td.text().includes('Grzyby'))
expect(cell?.text()).toBe('<b>Grzyby</b> rosną')
expect(cell?.find('b').exists()).toBe(false)
})
})
describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () => {
const STATS = `${BASE}/partials/stats`
const RECOUNT = `${BASE}/toolbar/recount`
/** Plugin files never load in happy-dom: appended scripts and links are recorded instead. */
function stubHead(): { scripts: string[]; links: HTMLLinkElement[] } {
const seen = { scripts: [] as string[], links: [] as HTMLLinkElement[] }
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) {
seen.scripts.push(node.getAttribute('src') ?? '')
queueMicrotask(() => node.dispatchEvent(new Event('load')))
return node
}
if (node instanceof HTMLLinkElement) {
seen.links.push(node)
return node
}
return append(node)
})
return seen
}
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return routes({
[`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture },
[`GET ${STATS}`]: { body: extensionPartialFixture },
...overrides,
})
}
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
return { route: () => pending, release }
}
it('renders the header partial slot only when the schema names one', async () => {
stubHead()
const plain = await mountApp('/acme/demo/widgets', routes())
expect(plain.wrapper.find('[data-partial]').exists()).toBe(false)
expect(plain.wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(plain.calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false)
plain.wrapper.unmount()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes())
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
expect(queryOf(requestsTo(calls, 'GET', STATS)[0]!).has('id')).toBe(false)
expect(wrapper.find('[data-partial="header"] dl.summer-stats').exists()).toBe(true)
})
it('refetches the header after a bulk delete and a toolbar action only', async () => {
stubHead()
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets',
extensionRoutes({
[`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } },
[`POST ${RECOUNT}`]: { body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } },
}),
{ attach: true },
)
const partials = () => requestsTo(calls, 'GET', STATS).length
expect(partials()).toBe(1)
for (const query of [{ search: 'blue' }, { sort: 'name', dir: 'desc' }, { 'filter[maker]': '1' }, { page: '2' }, { per_page: '50' }]) {
await router.replace({ path: '/acme/demo/widgets', query })
await flushPromises()
expect(partials(), JSON.stringify(query)).toBe(1)
}
await wrapper.find('[data-list-search]').setValue('red')
await wait(350)
await flushPromises()
expect(partials()).toBe(1)
await selectAllAndDelete(wrapper)
expect(partials()).toBe(2)
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(partials()).toBe(3)
})
it('runs a toolbar action: POSTs {}, toasts and reloads the list', async () => {
stubHead()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: action.route }))
const lists = requestsTo(calls, 'GET', BASE).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBe('true')
await wrapper.find('[data-action="recount"]').trigger('click')
const posts = requestsTo(calls, 'POST', RECOUNT)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({})
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } })
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Counts refreshed', tone: 'success' })
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined()
})
it('toasts nothing for an empty message but still reloads', async () => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: { body: { data: { message: '', fill: {} }, meta: {} } } }))
const lists = requestsTo(calls, 'GET', BASE).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value).toHaveLength(0)
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
})
it.each([
['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Recount failed.', details: {} } } }, 'Recount failed.'],
['the framework text without one', { status: 500, body: {} }, 'Nie udało się wykonać akcji. Spróbuj ponownie.'],
])('toasts danger with %s and does not reload', async (_, reply, text) => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: reply }))
const lists = requestsTo(calls, 'GET', BASE).length
const partials = requestsTo(calls, 'GET', STATS).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' })
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists)
expect(requestsTo(calls, 'GET', STATS).length).toBe(partials)
expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined()
})
it('toasts the framework text when the action cannot reach the server', async () => {
stubHead()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
extensionRoutes({
[`POST ${RECOUNT}`]: () => {
throw new TypeError('offline')
},
}),
)
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się wykonać akcji. Spróbuj ponownie.', tone: 'danger' })
})
it('loads the controller assets when the schema arrives, not before', async () => {
const seen = stubHead()
const schema = deferred()
const envelope = clone(extensionListSchemaFixture)
// Fresh URLs: the loader remembers files across the tests of this file.
envelope.data.assets.scripts = ['/admin-test/assets/acme/demo/js/list.js?v=1']
envelope.data.assets.styles = ['/admin-test/assets/acme/demo/css/list.css?v=1']
await mountApp('/acme/demo/widgets', extensionRoutes({ [`GET ${BASE}/schema/list`]: schema.route }))
expect(seen.links).toHaveLength(0)
expect(seen.scripts).toHaveLength(0)
schema.release({ body: envelope })
await flushPromises()
expect(seen.scripts).toEqual(['/admin-test/assets/acme/demo/js/list.js?v=1'])
expect(seen.links.map((link) => link.getAttribute('href'))).toEqual(['/admin-test/assets/acme/demo/css/list.css?v=1'])
expect(seen.links[0]!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets')
expect(seen.links[0]!.disabled).toBe(false)
})
})
describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
const PEOPLE = `${API}/acme/roster/people`
const strings = {
'backend::lang.list.bulk_actions': { other: 'Bulk actions' },
'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' },
'backend::lang.list.bulk_done': { one: 'Action completed for :count record.', other: 'Action completed for :count records.' },
'backend::lang.list.bulk_stale': { other: 'Some of the selected records are no longer available.' },
'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' },
'backend::lang.extension.action_failed': { other: 'The action could not be completed.' },
}
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${PEOPLE}/schema/list`]: { body: rosterListSchemaFixture },
[`GET ${PEOPLE}`]: { body: rosterListFixture },
...overrides,
}
}
const ran = (affected: number, message = ''): Reply => ({ body: { data: { message, affected }, meta: {} } })
const refused = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } })
const trigger = (wrapper: Mounted) => wrapper.find('[data-action="bulk-actions"]')
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
const listGets = (calls: Request[]) => requestsTo(calls, 'GET', PEOPLE).length
async function select(wrapper: Mounted, count: number): Promise<void> {
for (const box of wrapper.findAll('td[data-select] [role="checkbox"]').slice(0, count)) {
await box.trigger('click')
}
}
async function choose(wrapper: Mounted, name: string): Promise<void> {
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
document.body.querySelector<HTMLElement>(`[data-bulk-menu] [data-bulk-action="${name}"]`)!.click()
await flushPromises()
}
async function answer(action: 'confirm' | 'cancel'): Promise<void> {
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)!.click()
await flushPromises()
await wait(10)
await flushPromises()
}
beforeEach(() => {
setBundle({ ...langFixture.data, ...strings }, 'en')
})
it('offers the declared actions without the built-in delete, and no menu when none is permitted', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true })
await select(wrapper, 1)
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
const names = Array.from(document.body.querySelectorAll<HTMLElement>('[data-bulk-menu] [data-bulk-action]')).map((item) => item.dataset.bulkAction)
expect(names).toEqual(['activate', 'archive'])
wrapper.unmount()
document.body.innerHTML = ''
const only = clone(rosterListSchemaFixture)
only.data.bulkActions = [{ name: 'delete', label: 'Delete selected' }]
const none = await mountApp('/acme/roster/people', people({ [`GET ${PEOPLE}/schema/list`]: { body: only } }), { attach: true })
await select(none.wrapper, 1)
expect(trigger(none.wrapper).exists()).toBe(false)
expect(none.wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('asks with the action text, or the default naming the action and the count', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true })
await select(wrapper, 2)
await choose(wrapper, 'activate')
expect(dialog()!.textContent).toContain('Activate the selected people?')
await answer('cancel')
await choose(wrapper, 'archive')
expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?')
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Archive')
})
it('posts the selected ids once, toasts the outcome, clears the selection and reloads', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(2) }), { attach: true })
const before = listGets(calls)
await select(wrapper, 2)
await choose(wrapper, 'archive')
await answer('confirm')
const posts = requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({ ids: [1, 2] })
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Action completed for 2 records.', 'success']])
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
expect(listGets(calls)).toBe(before + 1)
})
it('shows the server message when the action sends one', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1, 'One person was archived.') }), { attach: true })
await select(wrapper, 1)
await choose(wrapper, 'archive')
await answer('confirm')
expect(toasts.value.map((toast) => toast.text)).toEqual(['One person was archived.'])
})
it('keeps the dialog busy and the trigger disabled until the request settles', async () => {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: () => pending }), { attach: true })
await select(wrapper, 1)
await choose(wrapper, 'archive')
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.disabled).toBe(true)
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
expect(toasts.value).toHaveLength(0)
release(ran(1))
await flushPromises()
await wait(10)
await flushPromises()
expect(dialog()).toBeNull()
expect(toasts.value).toHaveLength(1)
})
it.each([
[409, 'Conflict', 'Some of the selected records are no longer available.', false, 1],
[403, 'Only team leads may archive people.', 'Only team leads may archive people.', true, 0],
[403, '', 'You do not have permission to run this action.', true, 0],
[500, '', 'The action could not be completed.', true, 0],
])('on %i (%j): toast %j, selection kept %s, reloads %i', async (status, message, text, kept, reloads) => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(status, message) }), { attach: true })
const before = listGets(calls)
await select(wrapper, 2)
await choose(wrapper, 'archive')
await answer('confirm')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([[text, 'danger']])
expect(wrapper.find('[data-selected-pill]').exists()).toBe(kept)
expect(listGets(calls)).toBe(before + reloads)
})
it('backstop: focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(403, 'Refused.') }), { attach: true })
await select(wrapper, 1)
// By cancel: nothing is sent and the trigger has focus again.
await choose(wrapper, 'archive')
expect(dialog()).not.toBeNull()
expect(document.activeElement).not.toBe(trigger(wrapper).element)
await answer('cancel')
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(0)
expect(document.activeElement).toBe(trigger(wrapper).element)
// By Escape, which is a cancel.
await choose(wrapper, 'archive')
dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
await flushPromises()
await wait(10)
await flushPromises()
if (dialog()) {
await answer('cancel')
}
expect(document.activeElement).toBe(trigger(wrapper).element)
// By confirm: the request ran and the trigger has focus again.
;(document.activeElement as HTMLElement).blur()
await choose(wrapper, 'archive')
await answer('confirm')
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(1)
expect(document.activeElement).toBe(trigger(wrapper).element)
// The trigger is enabled, so the focus is on a control that can take it.
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
})
it('returns focus to the trigger before a successful run clears the selection', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1) }), { attach: true })
await select(wrapper, 1)
const focused: boolean[] = []
trigger(wrapper).element.addEventListener('focus', () => focused.push(trigger(wrapper).attributes('disabled') === undefined))
await choose(wrapper, 'archive')
await answer('confirm')
// Focus reached the trigger while it was still enabled.
expect(focused).toContain(true)
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
})
describe('list state in the URL and browser storage (D-01)', () => {
beforeEach(() => {
// Stored for the admin mountApp signs in (list state is per admin).
setListStateOwner(profile.data.id)
})
it('restores the stored query before the first list request when the URL has no list params', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc', filter: { active: 'true' } })
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const [first] = requestsTo(calls, 'GET', BASE)
const query = queryOf(first!)
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('filter[active]')).toBe('true')
expect(router.currentRoute.value.query).toMatchObject({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
})
it('lets a URL with list params win and stores its query', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc' })
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes())
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBeNull()
expect(readListState('acme.demo.widgets')).toEqual({ search: 'blue' })
})
})
describe('list state entry logic (D-01)', () => {
const ID = 'acme.demo.widgets'
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
beforeEach(() => {
setListStateOwner(profile.data.id)
})
function listQueries(calls: Request[]): URLSearchParams[] {
return requestsTo(calls, 'GET', BASE).map(queryOf)
}
it('makes no list request without the stored params when the URL has none', async () => {
writeListState(ID, { search: 'blue', sort: 'code', dir: 'desc', per_page: 50, filter: { active: 'true' } })
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = listQueries(calls)
expect(sent.length).toBeGreaterThanOrEqual(1)
for (const query of sent) {
expect(query.get('search')).toBe('blue')
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('per_page')).toBe('50')
expect(query.get('filter[active]')).toBe('true')
}
expect(router.currentRoute.value.query).toEqual({
search: 'blue',
sort: 'code',
dir: 'desc',
per_page: '50',
'filter[active]': 'true',
})
})
it('lets every list param of the URL win and overwrites the whole stored query', async () => {
writeListState(ID, { search: 'old', sort: 'code', dir: 'desc', filter: { active: 'true' } })
const { calls } = await mountApp('/acme/demo/widgets?sort=name&dir=asc&filter[active]=false', routes())
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(1)
expect(sent[0]!.get('sort')).toBe('name')
expect(sent[0]!.get('search')).toBeNull()
expect(sent[0]!.get('filter[active]')).toBe('false')
expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc', filter: { active: 'false' } })
})
it('forgets the stored query when every control is cleared, so the next entry loads unfiltered', async () => {
const first = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&filter[active]=true', routes())
await flushPromises()
expect(window.localStorage.getItem(KEY)).not.toBeNull()
await first.wrapper.find('[data-list-search]').setValue('')
await wait(350)
await flushPromises()
expect(first.router.currentRoute.value.query.search).toBeUndefined()
await first.wrapper.find('[data-sort="code"]').trigger('click')
await flushPromises()
expect(first.router.currentRoute.value.query.sort).toBeUndefined()
expect(window.localStorage.getItem(KEY)).not.toBeNull()
await first.wrapper.find('select[data-filter="active"]').setValue('')
await flushPromises()
expect(first.router.currentRoute.value.query).toEqual({})
expect(window.localStorage.getItem(KEY)).toBeNull()
first.wrapper.unmount()
const second = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = listQueries(second.calls)
expect(sent).toHaveLength(1)
expect([...sent[0]!.keys()]).toEqual([])
expect(second.router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
})
it('keeps the filters when the same list is opened again from the navigation', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets?sort=code&dir=desc&filter[active]=true', routes())
await flushPromises()
const before = requestsTo(calls, 'GET', BASE).length
await router.push('/acme/demo/widgets')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
for (const query of listQueries(calls).slice(before)) {
expect(query.get('sort')).toBe('code')
expect(query.get('filter[active]')).toBe('true')
}
expect(listQueries(calls).every((query) => query.get('filter[active]') === 'true')).toBe(true)
expect(readListState(ID)).toEqual({ sort: 'code', dir: 'desc', filter: { active: 'true' } })
})
it('drops the stored query when the list answers 422', async () => {
const invalid = {
status: 422,
body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } },
}
writeListState(ID, { sort: 'gone', dir: 'asc' })
const { calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: invalid }))
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBe('gone')
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(readListState(ID)).toEqual({})
})
it.each([
['from the URL', '/acme/demo/widgets?page=5', false],
['restored from storage', '/acme/demo/widgets', true],
])('moves a page beyond last_page %s to last_page and requests that page', async (_label, url, stored) => {
if (stored) {
writeListState(ID, { page: 5 })
}
const pageOf = (request: Request) => Number(queryOf(request).get('page') ?? '1')
const { calls, router } = await mountApp(
url,
routes({
[`GET ${BASE}`]: (request: Request): Reply => {
const page = pageOf(request)
return { body: { data: page <= 2 ? listFixture.data : [], meta: { page, per_page: 20, total: 25, last_page: 2 } } }
},
}),
)
await flushPromises()
expect(requestsTo(calls, 'GET', BASE).map(pageOf)).toEqual([5, 2])
expect(router.currentRoute.value.query).toEqual({ page: '2' })
expect(readListState(ID)).toEqual({ page: 2 })
})
})
describe('a restored query that fails falls back to the plain list once (WR-02)', () => {
const ID = 'acme.demo.widgets'
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
const invalid = {
status: 422,
body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } },
}
function listQueries(calls: Request[]): URLSearchParams[] {
return requestsTo(calls, 'GET', BASE).map(queryOf)
}
/** Fails every list request that carries a sort, answers the plain list. */
function failSorted(reply: Reply | 'throw'): Route {
return (request: Request): Reply => {
if (queryOf(request).get('sort') === null) {
return { body: listFixture }
}
if (reply === 'throw') {
throw new TypeError('Failed to fetch')
}
return reply
}
}
beforeEach(() => {
setListStateOwner(profile.data.id)
})
it.each([
['answers 500', serverError as Reply | 'throw'],
['answers 422', invalid as Reply | 'throw'],
['cannot reach the server', 'throw' as const],
])('forgets the stored query and shows the plain list when the restored request %s', async (_label, reply) => {
writeListState(ID, { sort: 'broken', dir: 'desc', filter: { active: 'true' } })
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(reply) }))
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect(sent[0]!.get('sort')).toBe('broken')
expect(sent[0]!.get('filter[active]')).toBe('true')
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.findAll('[data-sort]').length).toBeGreaterThan(0)
expect(wrapper.findAll('tbody tr')).toHaveLength(listFixture.data.length)
})
it('falls back exactly once: a failing plain list shows the error and asks no more', async () => {
writeListState(ID, { sort: 'broken', dir: 'asc' })
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
await flushPromises()
await wait(20)
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect(sent[0]!.get('sort')).toBe('broken')
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
// Nothing to reset: the plain list itself failed.
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
expect(window.localStorage.getItem(KEY)).toBeNull()
})
it('restores again on the next entry once a working query is stored', async () => {
writeListState(ID, { sort: 'broken', dir: 'asc' })
const first = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(serverError) }))
await flushPromises()
expect(listQueries(first.calls)).toHaveLength(2)
first.wrapper.unmount()
writeListState(ID, { search: 'blue' })
const second = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = listQueries(second.calls)
expect(sent).toHaveLength(1)
expect(sent[0]!.get('search')).toBe('blue')
})
it('keeps a failing URL query on screen with the error and a reset, and stores it as before', async () => {
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets?sort=broken&dir=desc',
routes({ [`GET ${BASE}`]: failSorted(serverError) }),
)
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'desc' })
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(readListState(ID)).toEqual({ sort: 'broken', dir: 'desc' })
const reset = wrapper.find('[data-action="reset-list"]')
expect(reset.text()).toBe('Wyczyść wyszukiwanie, filtry i sortowanie')
await reset.trigger('click')
await flushPromises()
const sent = listQueries(calls)
expect(sent).toHaveLength(2)
expect([...sent[1]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.find('table').exists()).toBe(true)
})
it('keeps a URL query that answers 422 on screen, drops it from storage and offers the reset', async () => {
writeListState(ID, { search: 'older' })
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets?sort=broken&dir=asc',
routes({ [`GET ${BASE}`]: failSorted(invalid) }),
)
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'asc' })
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(true)
})
it('shows no reset when the plain list fails from the URL', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
await flushPromises()
expect(listQueries(calls)).toHaveLength(1)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
})
})
describe('stored list queries are per admin (WR-03, T-16.1-03)', () => {
const ID = 'acme.demo.widgets'
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
it("never restores another admin's query and removes it when the next admin signs in", async () => {
setListStateOwner(profile.data.id)
writeListState(ID, { search: 'jan.kowalski@example.test' })
expect(window.localStorage.getItem(`${LIST_STATE_PREFIX}${profile.data.id}:${ID}`)).not.toBeNull()
const { calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${API}/auth/me`]: { body: other } }))
await flushPromises()
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
expect(sent).toHaveLength(1)
expect([...sent[0]!.keys()]).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
})
it('never restores an unscoped key of an older build and sweeps it', async () => {
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
const { calls } = await mountApp('/acme/demo/widgets', routes())
await flushPromises()
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
expect(sent).toHaveLength(1)
expect(sent[0]!.get('search')).toBeNull()
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
})
it('stores the next query under the signed-in admin only', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes({ [`GET ${API}/auth/me`]: { body: other } }))
await flushPromises()
expect(requestsTo(calls, 'GET', BASE)).toHaveLength(1)
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([
`${LIST_STATE_PREFIX}8:${ID}`,
])
})
})