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:
@@ -4,7 +4,7 @@ import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
@@ -22,13 +22,9 @@ async function boot(): Promise<void> {
|
||||
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
|
||||
}
|
||||
const router = createAdminRouter()
|
||||
onUnauthorized(() => {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } })
|
||||
}
|
||||
})
|
||||
// A session that cannot be recovered ends here: the profile and every
|
||||
// stored list query are cleared before the login screen shows (T-16.1-03).
|
||||
onUnauthorized(() => expireSession(router))
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Per-list query memory (D-01). The list query (search, sort, dir, page,
|
||||
// per_page and filter[<name>]) lives in the URL; every query a list loads is
|
||||
// also kept in localStorage under LIST_STATE_PREFIX plus the controller id,
|
||||
// one key per list, so a list opened without list params (navigation,
|
||||
// breadcrumb, a form's back, cancel, save or delete) can restore it. Only
|
||||
// these list query parameters are stored, never a token or record data, and
|
||||
// logout removes every key. Disabled or full storage is a no-op: the URL
|
||||
// still carries the state.
|
||||
// also kept in localStorage, one key per signed-in admin and list:
|
||||
// LIST_STATE_PREFIX + "<admin id>:" + controller id. A list opened without
|
||||
// list params (navigation, breadcrumb, a form's back, cancel, save or delete)
|
||||
// can then restore it. Only these list query parameters are stored, never a
|
||||
// token or record data. Search terms can hold personal data, so stored
|
||||
// queries never outlive the session or cross admins (T-16.1-03): useAuth sets
|
||||
// the owner when the profile loads, which removes every list key of any other
|
||||
// admin and the unscoped keys of older builds, and clears the owner on every
|
||||
// session end (logout, expiry, a 401 the refresh cannot recover), which
|
||||
// removes every list key. Without an owner nothing is read or written.
|
||||
// Disabled or full storage is a no-op: the URL still carries the state.
|
||||
import type { ListQuery } from '../api/types'
|
||||
import { listQueryKey, parseListQuery, toListQuery } from '../app/listQuery'
|
||||
|
||||
@@ -13,6 +18,9 @@ export const LIST_STATE_PREFIX = 'summer-admin.list:'
|
||||
|
||||
const EMPTY_KEY = listQueryKey({})
|
||||
|
||||
// The signed-in admin's id as a string, or null when nobody is signed in.
|
||||
let owner: string | null = null
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
@@ -21,8 +29,13 @@ function storage(): Storage | null {
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string {
|
||||
return LIST_STATE_PREFIX + controllerId
|
||||
/** The key prefix of one admin's lists. */
|
||||
export function listStateOwnerPrefix(adminId: number | string): string {
|
||||
return `${LIST_STATE_PREFIX}${adminId}:`
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string | null {
|
||||
return owner === null ? null : listStateOwnerPrefix(owner) + controllerId
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
@@ -33,13 +46,60 @@ function remove(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every list key for which drop answers true. */
|
||||
function removeListKeys(drop: (key: string) => boolean): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX) && drop(key)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the admin whose list queries are read and written. A signed-in admin
|
||||
* id removes every list key that is not theirs: another admin's queries and
|
||||
* the unscoped `summer-admin.list:<controller>` keys of older builds. Null
|
||||
* (signed out) removes every list key.
|
||||
*/
|
||||
export function setListStateOwner(adminId: number | string | null): void {
|
||||
const next = adminId === null || String(adminId) === '' ? null : String(adminId)
|
||||
owner = next
|
||||
if (next === null) {
|
||||
removeListKeys(() => true)
|
||||
return
|
||||
}
|
||||
const prefix = listStateOwnerPrefix(next)
|
||||
removeListKeys((key) => !key.startsWith(prefix))
|
||||
}
|
||||
|
||||
/** The id whose list queries are kept, or null when nobody is signed in. */
|
||||
export function listStateOwner(): string | null {
|
||||
return owner
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored query of a list. Stored values pass through parseListQuery like
|
||||
* a hand-edited URL; anything that is not an object of strings is removed and
|
||||
* reads as the empty query.
|
||||
* reads as the empty query. Without an owner it is always the empty query.
|
||||
*/
|
||||
export function readListState(controllerId: string): ListQuery {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return {}
|
||||
}
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage()?.getItem(key) ?? null
|
||||
@@ -69,9 +129,15 @@ export function readListState(controllerId: string): ListQuery {
|
||||
return parseListQuery(value as Record<string, string>)
|
||||
}
|
||||
|
||||
/** Stores a list's query; the empty query removes the key instead. */
|
||||
/**
|
||||
* Stores a list's query for the owner; the empty query removes the key
|
||||
* instead. Without an owner nothing is stored.
|
||||
*/
|
||||
export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return
|
||||
}
|
||||
if (listQueryKey(query) === EMPTY_KEY) {
|
||||
remove(key)
|
||||
return
|
||||
@@ -83,24 +149,7 @@ export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every stored list query (logout). */
|
||||
/** Removes every stored list query of every admin, keeping the owner. */
|
||||
export function clearListStates(): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
removeListKeys(() => true)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { clearListStates } from './listState'
|
||||
import { setListStateOwner } from './listState'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
@@ -48,10 +48,15 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
return true
|
||||
}
|
||||
|
||||
/** Loads the signed-in admin; null when the session is missing or expired. */
|
||||
/**
|
||||
* Loads the signed-in admin; null when the session is missing or expired.
|
||||
* The stored list queries follow the answer: a profile keeps only that
|
||||
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
|
||||
*/
|
||||
export async function me(): Promise<AdminProfile | null> {
|
||||
const { data, response } = await api.GET('/auth/me')
|
||||
user.value = response.ok && data ? data.data : null
|
||||
setListStateOwner(user.value?.id ?? null)
|
||||
return user.value
|
||||
}
|
||||
|
||||
@@ -73,12 +78,35 @@ export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_na
|
||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||
export function setCurrentUser(profile: AdminProfile): void {
|
||||
user.value = profile
|
||||
setListStateOwner(profile.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the session on this device: the refresh timer, the profile and
|
||||
* every stored list query (search terms can hold personal data and never
|
||||
* outlive the session, T-16.1-03).
|
||||
*/
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
setListStateOwner(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session ended on its own (expiry, or a 401 the refresh could not
|
||||
* recover): clears the local session, stored list queries included, and
|
||||
* sends the admin to the login screen with the current path as redirect.
|
||||
*/
|
||||
export function expireSession(router: {
|
||||
currentRoute: { value: Pick<RouteLocationNormalizedLoaded, 'name' | 'fullPath'> }
|
||||
push: Router['push']
|
||||
}): void {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +123,6 @@ export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
// Network failure: the session still ends on this device.
|
||||
} finally {
|
||||
clearUser()
|
||||
clearListStates()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
}
|
||||
@@ -110,6 +137,7 @@ export function useAuth() {
|
||||
expiresIn: readonly(expiresIn),
|
||||
login,
|
||||
logout,
|
||||
expireSession,
|
||||
me,
|
||||
setCurrentUser,
|
||||
clearUser,
|
||||
|
||||
@@ -28,6 +28,10 @@ import { showToast } from '../state/useToasts'
|
||||
// kept per list in browser storage (listState.ts, D-01): entered without list
|
||||
// params, the list restores the stored query into the URL before its first
|
||||
// list request; a URL with list params wins and overwrites the stored query.
|
||||
// A restored query the admin did not ask for never breaks the list: if its
|
||||
// load fails for any reason, the stored query is forgotten and the plain list
|
||||
// loads once instead (WR-02). A failed URL query keeps the error, with a reset
|
||||
// back to the plain list next to it.
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
@@ -124,21 +128,47 @@ function replaceQuery(next: ListQuery): void {
|
||||
|
||||
const EMPTY_QUERY_KEY = listQueryKey({})
|
||||
|
||||
let generation = 0
|
||||
const hasQuery = computed(() => listQueryKey(query.value) !== EMPTY_QUERY_KEY)
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
let generation = 0
|
||||
// Key of the query the watch restored from storage and has not loaded yet.
|
||||
let restoredKey: string | null = null
|
||||
|
||||
/** Drops every list param: the stored query is forgotten, the plain list loads. */
|
||||
function resetQuery(): void {
|
||||
replaceQuery({})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the rows of the URL query. restored marks the first load of a query
|
||||
* restored from storage: if it fails for any reason (422, 500, network), the
|
||||
* stored query is forgotten and the plain list loads. The plain list is never
|
||||
* restored again, so this happens at most once per entry.
|
||||
*/
|
||||
async function loadList(restored = false): Promise<void> {
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
listFailed.value = false
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
let result: Awaited<ReturnType<typeof fetchList>> | null = null
|
||||
try {
|
||||
result = await fetchList()
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
rows.value = result.data?.data ?? []
|
||||
meta.value = result.data?.meta ?? null
|
||||
listFailed.value = !result.data
|
||||
if (!result?.data && restored) {
|
||||
// Keeps loading until the plain list answers; no error flashes.
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
rows.value = result?.data?.data ?? []
|
||||
meta.value = result?.data?.meta ?? null
|
||||
listFailed.value = !result?.data
|
||||
loading.value = false
|
||||
if (result.response?.status === 422) {
|
||||
if (result?.response?.status === 422) {
|
||||
// A stale stored filter or sort never sticks.
|
||||
writeListState(controllerId, {})
|
||||
return
|
||||
@@ -150,6 +180,10 @@ async function loadList(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchList() {
|
||||
return api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||
schema.value = result.data?.data ?? null
|
||||
@@ -170,15 +204,19 @@ watch(
|
||||
// restore the stored query into the URL and let this watch load it.
|
||||
if (key === EMPTY_QUERY_KEY) {
|
||||
const stored = readListState(controllerId)
|
||||
if (listQueryKey(stored) !== EMPTY_QUERY_KEY) {
|
||||
const storedKey = listQueryKey(stored)
|
||||
if (storedKey !== EMPTY_QUERY_KEY) {
|
||||
restoredKey = storedKey
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
return
|
||||
}
|
||||
}
|
||||
const restored = restoredKey !== null && restoredKey === key
|
||||
restoredKey = null
|
||||
writeListState(controllerId, query.value)
|
||||
selected.value = []
|
||||
searchText.value = query.value.search ?? ''
|
||||
void loadList()
|
||||
void loadList(restored)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -417,9 +455,16 @@ async function onAction(name: string): Promise<void> {
|
||||
:source="path"
|
||||
@update="onFilter"
|
||||
/>
|
||||
<p v-if="failed && !loading" role="alert" class="border-t border-border px-5 py-4 text-danger">
|
||||
{{ t('backend::lang.list.load_failed') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failed && !loading"
|
||||
data-list-failed
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4"
|
||||
>
|
||||
<p role="alert" class="text-danger">{{ t('backend::lang.list.load_failed') }}</p>
|
||||
<Button v-if="listFailed && hasQuery" data-action="reset-list" @click="resetQuery">
|
||||
{{ t('backend::lang.list.reset_list') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="schema?.columns ?? []"
|
||||
|
||||
3
admin/tests/fixtures/lang.json
vendored
3
admin/tests/fixtures/lang.json
vendored
@@ -186,6 +186,9 @@
|
||||
"backend::lang.list.records_per_page": {
|
||||
"other": "Na stronę"
|
||||
},
|
||||
"backend::lang.list.reset_list": {
|
||||
"other": "Wyczyść wyszukiwanie, filtry i sortowanie"
|
||||
},
|
||||
"backend::lang.list.results": {
|
||||
"one": ":count wynik",
|
||||
"few": ":count wyniki",
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { readListState, setListStateOwner, writeListState } from '../../src/state/listState'
|
||||
import {
|
||||
clone,
|
||||
extensionFormSchemaFixture,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
rosterFormSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
import { API, mountApp, profile, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
@@ -697,6 +697,8 @@ describe('returning to the list keeps its query (D-01)', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Stored for the admin mountApp signs in (list state is per admin).
|
||||
setListStateOwner(profile.data.id)
|
||||
writeListState(LIST_ID, STORED)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState'
|
||||
import {
|
||||
clearListStates,
|
||||
LIST_STATE_PREFIX,
|
||||
listStateOwner,
|
||||
listStateOwnerPrefix,
|
||||
readListState,
|
||||
setListStateOwner,
|
||||
writeListState,
|
||||
} from '../../src/state/listState'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
|
||||
// Browser-storage helpers of the per-list query memory (D-01).
|
||||
// Browser-storage helpers of the per-list query memory (D-01), scoped to the
|
||||
// signed-in admin (WR-03, T-16.1-03).
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
const OWNER = 7
|
||||
const KEY = `${LIST_STATE_PREFIX}${OWNER}:${ID}`
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setListStateOwner(OWNER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setListStateOwner(null)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('list state keys', () => {
|
||||
it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => {
|
||||
it('uses the summer-admin.list: prefix plus the admin id and the controller id, one key per list', () => {
|
||||
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
|
||||
expect(listStateOwnerPrefix(OWNER)).toBe('summer-admin.list:7:')
|
||||
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',
|
||||
'summer-admin.list:7:acme.demo.gadgets',
|
||||
'summer-admin.list:7:acme.demo.widgets',
|
||||
])
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
|
||||
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
|
||||
@@ -120,6 +133,69 @@ describe('writeListState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list state owner', () => {
|
||||
const OTHER_KEY = `${LIST_STATE_PREFIX}8:${ID}`
|
||||
const LEGACY_KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
|
||||
it('keeps the owner as a string and null when signed out', () => {
|
||||
expect(listStateOwner()).toBe('7')
|
||||
setListStateOwner(null)
|
||||
expect(listStateOwner()).toBeNull()
|
||||
setListStateOwner('')
|
||||
expect(listStateOwner()).toBeNull()
|
||||
})
|
||||
|
||||
it("never reads another admin's query and removes it when the owner changes", () => {
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
setListStateOwner(8)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(readListState(ID)).toEqual({})
|
||||
writeListState(ID, { sort: 'name', dir: 'asc' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([OTHER_KEY])
|
||||
})
|
||||
|
||||
it("does not read another admin's key even before any sweep", () => {
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(OTHER_KEY)).not.toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the owner's own keys when the same owner is set again", () => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner('7')
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('sweeps the unscoped keys of older builds and never reads them', () => {
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
})
|
||||
|
||||
it('does not take an admin id that only starts with the owner id for the owner', () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}70:${ID}`, JSON.stringify({ search: 'seventy' }))
|
||||
writeListState(ID, { search: 'seven' })
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('removes every list key and reads and writes nothing once signed out', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner(null)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ search: 'planted' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearListStates', () => {
|
||||
it('removes every list key and keeps the sidebar preference and other keys', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
@@ -127,6 +203,8 @@ describe('clearListStates', () => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
|
||||
writeListState('acme.other.people', { filter: { active: 'true' } })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}8:${ID}`, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
clearListStates()
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
@@ -156,6 +234,8 @@ describe('storage that throws', () => {
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
expect(() => setListStateOwner(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('drops a malformed value even when the removal throws', () => {
|
||||
@@ -174,5 +254,6 @@ describe('storage that throws', () => {
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { api, onUnauthorized } from '../../src/api/client'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import {
|
||||
clearUser,
|
||||
currentUser,
|
||||
expireSession,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
@@ -9,7 +13,7 @@ import {
|
||||
setCurrentUser,
|
||||
useAuth,
|
||||
} from '../../src/state/useAuth'
|
||||
import { writeListState } from '../../src/state/listState'
|
||||
import { LIST_STATE_PREFIX, listStateOwner, readListState, 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'
|
||||
@@ -166,6 +170,129 @@ describe('logout (T-10-23)', () => {
|
||||
expect(auth.logout).toBe(logout)
|
||||
expect(auth.me).toBe(me)
|
||||
expect(auth.clearUser).toBe(clearUser)
|
||||
expect(auth.expireSession).toBe(expireSession)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored list queries follow the session (WR-03, T-16.1-03)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
|
||||
const listKeys = () => Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))
|
||||
|
||||
async function signInAs(envelope: typeof profile, routes: Parameters<typeof mockApi>[0] = {}) {
|
||||
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: envelope }, ...routes })
|
||||
await me()
|
||||
return calls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
onUnauthorized(null)
|
||||
})
|
||||
|
||||
it('scopes the stored queries to the admin me() loads', async () => {
|
||||
await signInAs(profile)
|
||||
expect(listStateOwner()).toBe('7')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([`${LIST_STATE_PREFIX}7:${ID}`])
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it("never hands one admin's query to the next admin who signs in on this browser", async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
||||
|
||||
// The tab was closed without a logout; another admin signs in.
|
||||
await signInAs(other)
|
||||
expect(listStateOwner()).toBe('8')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the own queries when the same admin loads again or edits the profile', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
await signInAs(profile)
|
||||
setCurrentUser({ ...profile.data, first_name: 'Grace' })
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('removes every stored query when me() finds no session', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
||||
expect(await me()).toBeNull()
|
||||
expect(listStateOwner()).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('sweeps unscoped keys of older builds when the profile loads', async () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
await signInAs(profile)
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('clearUser removes every stored query and stops storing', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
clearUser()
|
||||
expect(listKeys()).toEqual([])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('expireSession clears the session and the stored queries and routes to login with the redirect', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets?search=blue' } }, push })
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(push).toHaveBeenCalledWith({ name: 'login', query: { redirect: '/acme/demo/widgets?search=blue' } })
|
||||
})
|
||||
|
||||
it('expireSession stays on the login screen and swallows a failed navigation', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'login', fullPath: '/login' } }, push })
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listKeys()).toEqual([])
|
||||
|
||||
await signInAs(profile)
|
||||
const failing = vi.fn().mockRejectedValue(new Error('aborted'))
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets' } }, push: failing })
|
||||
await Promise.resolve()
|
||||
expect(failing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a 401 the refresh cannot recover clears the stored queries through the onUnauthorized handler of main.ts', async () => {
|
||||
const calls = await signInAs(profile, {
|
||||
[`GET ${API}/acme/demo/widgets`]: unauthorized,
|
||||
[`POST ${API}/auth/refresh`]: unauthorized,
|
||||
})
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets?search=blue')
|
||||
await router.isReady()
|
||||
// The same wiring as main.ts.
|
||||
onUnauthorized(() => expireSession(router))
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
expect(listKeys()).toHaveLength(1)
|
||||
|
||||
await api.GET('/{vendor}/{plugin}/{controller}', {
|
||||
params: { path: { vendor: 'acme', plugin: 'demo', controller: 'widgets' }, query: {} },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?search=blue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user