test(admin): list state round trip and storage coverage

- Form back, cancel, save-and-close and delete land on the list with the stored query; every list request after the return carries it
- listState helpers: key prefix, malformed stored values removed, empty query stores nothing, throwing storage
- ListView entry: URL wins, cleared controls forget the query, same-list navigation, 422 forgets, page clamp
- Logout removes every list key and keeps the sidebar preference
This commit is contained in:
2026-10-09 22:42:36 +02:00
parent 3b8527b397
commit a8bce9c01f
4 changed files with 404 additions and 0 deletions

View File

@@ -5,11 +5,14 @@ import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import { readListState, writeListState } from '../../src/state/listState'
import {
clone,
extensionFormSchemaFixture,
formSchemaFixture,
langFixture,
listFixture,
listSchemaFixture,
recordFixture,
rosterFormSchemaFixture,
rosterRecordFixture,
@@ -662,3 +665,83 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
expect(body.name).toEqual({ en: 'Ada\nLovelace', pl: 'Ada po polsku' })
})
})
describe('returning to the list keeps its query (D-01)', () => {
const LIST_ID = 'acme.demo.widgets'
const STORED = { sort: 'code', dir: 'desc' as const, filter: { active: 'true' } }
/** The form routes plus the list schema and rows, so the form can return to the list. */
function roundTrip(overrides: Record<string, Route> = {}): Record<string, Route> {
return routes({
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
[`GET ${BASE}`]: { body: listFixture },
[`PUT ${RECORD}`]: { body: recordFixture },
[`DELETE ${RECORD}`]: { body: { data: { deleted: true }, meta: {} } },
...overrides,
})
}
/** The list lands on its path with the stored query, and every list request carries it. */
function expectRestored(router: { currentRoute: { value: { path: string; query: Record<string, unknown> } } }, calls: Request[]) {
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
const lists = requestsTo(calls, 'GET', BASE)
expect(lists.length).toBeGreaterThanOrEqual(1)
for (const request of lists) {
const query = queryOf(request)
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('filter[active]')).toBe('true')
}
expect(readListState(LIST_ID)).toEqual(STORED)
}
beforeEach(() => {
writeListState(LIST_ID, STORED)
})
it('tracer: the back action lands on the list with the stored query before the first list request', async () => {
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
expect(requestsTo(calls, 'GET', BASE)).toHaveLength(0)
await wrapper.find('[data-action="back"]').trigger('click')
await flushPromises()
expectRestored(router, calls)
})
it('cancel lands on the list with the stored query', async () => {
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expectRestored(router, calls)
})
it('save and close follows a redirectClose to the list and lands with the stored query', async () => {
expect(formSchemaFixture.data.redirects.update.redirectClose).toBe('acme/demo/widgets')
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
await wrapper.find('[data-action="save-close"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expectRestored(router, calls)
})
it('delete, once confirmed, lands on the list with the stored query', async () => {
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip(), { attach: true })
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(1)
expectRestored(router, calls)
})
it('lands on the unfiltered list when nothing is stored', async () => {
writeListState(LIST_ID, {})
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
await wrapper.find('[data-action="back"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
const lists = requestsTo(calls, 'GET', BASE)
expect(lists).toHaveLength(1)
expect([...queryOf(lists[0]!).keys()]).toEqual([])
})
})

View File

@@ -515,3 +515,126 @@ describe('list state in the URL and browser storage (D-01)', () => {
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:${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 })
})
})

View File

@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState'
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
// Browser-storage helpers of the per-list query memory (D-01).
const ID = 'acme.demo.widgets'
const KEY = `${LIST_STATE_PREFIX}${ID}`
beforeEach(() => {
window.localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
window.localStorage.clear()
})
describe('list state keys', () => {
it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => {
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
writeListState(ID, { search: 'blue' })
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
expect(Object.keys(window.localStorage).sort()).toEqual([
'summer-admin.list:acme.demo.gadgets',
'summer-admin.list:acme.demo.widgets',
])
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
})
it('reads a missing key as the empty query', () => {
expect(readListState(ID)).toEqual({})
expect(window.localStorage.getItem(KEY)).toBeNull()
})
})
describe('readListState', () => {
it.each([
['invalid JSON', '{"search": '],
['a JSON string', '"search=blue"'],
['a JSON number', '42'],
['JSON null', 'null'],
['an array', '["search", "blue"]'],
['an object with a number value', '{"search": "blue", "page": 2}'],
['an object with a nested object', '{"filter": {"active": "true"}}'],
['an object with a boolean value', '{"filter[active]": true}'],
])('returns the empty query for %s and removes the key', (_label, raw) => {
window.localStorage.setItem(KEY, raw)
expect(readListState(ID)).toEqual({})
expect(window.localStorage.getItem(KEY)).toBeNull()
})
it('drops malformed values through the strict parser like a hand-edited URL', () => {
window.localStorage.setItem(
KEY,
JSON.stringify({
search: ' ',
dir: 'sideways',
page: '0',
per_page: 'many',
'filter[active]': '',
'filter[maker]': '3',
unknown: 'value',
'filter[': 'x',
}),
)
expect(readListState(ID)).toEqual({ filter: { maker: '3' } })
})
it('reads an unknown direction next to a sort as ascending', () => {
window.localStorage.setItem(KEY, JSON.stringify({ sort: 'code', dir: 'sideways' }))
expect(readListState(ID)).toEqual({ sort: 'code', dir: 'asc' })
})
it('keeps a well-formed stored object of strings in place', () => {
window.localStorage.setItem(KEY, JSON.stringify({ search: 'blue', page: '3' }))
expect(readListState(ID)).toEqual({ search: 'blue', page: 3 })
expect(window.localStorage.getItem(KEY)).not.toBeNull()
})
})
describe('writeListState', () => {
it('round-trips search, sort, dir, a page above 1, per_page and filters', () => {
const query = {
search: 'blue',
sort: 'code',
dir: 'desc' as const,
page: 3,
per_page: 50,
filter: { active: 'true', maker: '7' },
}
writeListState(ID, query)
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({
search: 'blue',
sort: 'code',
dir: 'desc',
page: '3',
per_page: '50',
'filter[active]': 'true',
'filter[maker]': '7',
})
expect(readListState(ID)).toEqual(query)
})
it('stores the first page as no page', () => {
writeListState(ID, { sort: 'name', dir: 'asc', page: 1 })
expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc' })
})
it.each([
['the empty query', {}],
['a query of empty filter values', { filter: { active: '' } }],
['a first page only', { page: 1 }],
])('removes the key for %s instead of storing it', (_label, query) => {
writeListState(ID, { search: 'blue' })
expect(window.localStorage.getItem(KEY)).not.toBeNull()
writeListState(ID, query)
expect(window.localStorage.getItem(KEY)).toBeNull()
expect(Object.keys(window.localStorage)).toEqual([])
})
})
describe('clearListStates', () => {
it('removes every list key and keeps the sidebar preference and other keys', () => {
window.localStorage.setItem(SIDEBAR_KEY, 'true')
window.localStorage.setItem('unrelated', 'kept')
writeListState(ID, { search: 'blue' })
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
writeListState('acme.other.people', { filter: { active: 'true' } })
clearListStates()
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
})
it('is a no-op on empty storage', () => {
expect(() => clearListStates()).not.toThrow()
expect(Object.keys(window.localStorage)).toEqual([])
})
})
describe('storage that throws', () => {
it('reads the empty query and writes and clears without throwing', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError')
})
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
vi.spyOn(Storage.prototype, 'key').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
expect(readListState(ID)).toEqual({})
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
expect(() => writeListState(ID, {})).not.toThrow()
expect(() => clearListStates()).not.toThrow()
})
it('drops a malformed value even when the removal throws', () => {
window.localStorage.setItem(KEY, '{broken')
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
expect(readListState(ID)).toEqual({})
})
it('treats a window whose localStorage getter throws as no storage', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
expect(readListState(ID)).toEqual({})
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
expect(() => writeListState(ID, {})).not.toThrow()
expect(() => clearListStates()).not.toThrow()
})
})

View File

@@ -9,7 +9,9 @@ import {
setCurrentUser,
useAuth,
} from '../../src/state/useAuth'
import { writeListState } from '../../src/state/listState'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
import { setSettings, settings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
@@ -129,6 +131,24 @@ describe('logout (T-10-23)', () => {
}
})
it.each([
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
['fails on the network', 'throw' as const],
])('removes every stored list query and keeps the sidebar preference when the call %s', async (_label, route) => {
await signedIn(route)
window.localStorage.setItem(SIDEBAR_KEY, 'true')
writeListState('acme.demo.widgets', { search: 'blue', sort: 'code', dir: 'desc' })
writeListState('acme.demo.gadgets', { filter: { active: 'true' } })
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toHaveLength(2)
await logout({ replace: vi.fn().mockResolvedValue(undefined) })
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toEqual([])
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
})
it('works without a router and swallows a failed navigation', async () => {
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
await expect(logout()).resolves.toBeUndefined()