fix(admin): keep list state in the URL and browser storage

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

View File

@@ -0,0 +1,106 @@
// 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.
import type { ListQuery } from '../api/types'
import { listQueryKey, parseListQuery, toListQuery } from '../app/listQuery'
export const LIST_STATE_PREFIX = 'summer-admin.list:'
const EMPTY_KEY = listQueryKey({})
function storage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage
} catch {
return null
}
}
function keyOf(controllerId: string): string {
return LIST_STATE_PREFIX + controllerId
}
function remove(key: string): void {
try {
storage()?.removeItem(key)
} catch {
// Storage may be disabled; nothing was kept then.
}
}
/**
* 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.
*/
export function readListState(controllerId: string): ListQuery {
const key = keyOf(controllerId)
let raw: string | null = null
try {
raw = storage()?.getItem(key) ?? null
} catch {
return {}
}
if (raw === null) {
return {}
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
remove(key)
return {}
}
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype ||
!Object.values(value).every((item) => typeof item === 'string')
) {
remove(key)
return {}
}
return parseListQuery(value as Record<string, string>)
}
/** Stores a list's query; the empty query removes the key instead. */
export function writeListState(controllerId: string, query: ListQuery): void {
const key = keyOf(controllerId)
if (listQueryKey(query) === EMPTY_KEY) {
remove(key)
return
}
try {
storage()?.setItem(key, JSON.stringify(toListQuery(query)))
} catch {
// Storage may be full or disabled; the URL still carries the state.
}
}
/** Removes every stored list query (logout). */
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.
}
}