fix(admin): scope remembered list state to the signed-in admin and recover from bad restored queries
- Store list queries under summer-admin.list:<admin id>:<controller>; me() and setCurrentUser set the owner, which removes other admins' keys and the unscoped keys of older builds; nothing is read or written without one - clearUser removes every stored query, so logout, session expiry and an unrecoverable 401 (expireSession, now used by main.ts) all clear them - A list request built from a restored query that fails for any reason (422, 500, network) forgets the query and loads the plain list once - A failing URL query keeps the error and offers a reset button (backend::lang.list.reset_list, en and pl) - Document remembered list state in docs/backend/lists-and-filters.md - Rebuild modules/boardwalk/dist
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { readListState, writeListState } from '../../src/state/listState'
|
||||
import { LIST_STATE_PREFIX, readListState, setListStateOwner, writeListState } from '../../src/state/listState'
|
||||
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
|
||||
import {
|
||||
clone,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
rosterListFixture,
|
||||
rosterListSchemaFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
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: {} } } }
|
||||
@@ -495,6 +495,11 @@ describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
|
||||
})
|
||||
|
||||
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())
|
||||
@@ -518,7 +523,11 @@ describe('list state in the URL and browser storage (D-01)', () => {
|
||||
|
||||
describe('list state entry logic (D-01)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `summer-admin.list:${ID}`
|
||||
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)
|
||||
@@ -638,3 +647,172 @@ describe('list state entry logic (D-01)', () => {
|
||||
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}`,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user