- 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
156 lines
4.7 KiB
TypeScript
156 lines
4.7 KiB
TypeScript
// 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, 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'
|
|
|
|
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
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** 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 {
|
|
try {
|
|
storage()?.removeItem(key)
|
|
} catch {
|
|
// Storage may be disabled; nothing was kept then.
|
|
}
|
|
}
|
|
|
|
/** 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. 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
|
|
} 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 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
|
|
}
|
|
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 of every admin, keeping the owner. */
|
|
export function clearListStates(): void {
|
|
removeListKeys(() => true)
|
|
}
|