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:
106
admin/src/state/listState.ts
Normal file
106
admin/src/state/listState.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { readonly, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { clearListStates } from './listState'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
@@ -82,8 +83,9 @@ export function clearUser(): void {
|
||||
|
||||
/**
|
||||
* Ends the session: POST /auth/logout (the server blacklists the token and
|
||||
* expires the HttpOnly cookie), then clears the in-memory user, navigation
|
||||
* and settings and routes to the login screen. The local state is cleared
|
||||
* expires the HttpOnly cookie), then clears the in-memory user, navigation,
|
||||
* settings and stored list queries (so the next admin on this browser sees
|
||||
* none of the previous searches) and routes to the login screen. The local state is cleared
|
||||
* and the login screen shown even when the call fails (T-10-23).
|
||||
*/
|
||||
export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
@@ -93,6 +95,7 @@ export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
// Network failure: the session still ends on this device.
|
||||
} finally {
|
||||
clearUser()
|
||||
clearListStates()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// D-06, D-10). The section panel hides below about 1100px or when the admin
|
||||
// collapses it. The viewport only forces the collapse and never overwrites
|
||||
// the admin's own choice, which persists in localStorage as a boolean under
|
||||
// SIDEBAR_KEY. Nothing else is ever written to browser storage (T-10-22).
|
||||
// SIDEBAR_KEY. The only other writer of browser storage is listState.ts,
|
||||
// which keeps list query parameters per list; tokens and record data are
|
||||
// never stored (T-10-22, T-16.1-03).
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
export const SIDEBAR_KEY = 'summer-admin.sidebar'
|
||||
|
||||
@@ -17,13 +17,17 @@ import PartialHost from '../components/partial/PartialHost.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { readListState, writeListState } from '../state/listState'
|
||||
import { activeEntry } from '../state/useNavigation'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// Schema-driven list (design screen 3, D-12, D-13, D-14, D-22). Search, sort,
|
||||
// page, per_page and filters live in the URL query and every change is a
|
||||
// router replace; any change clears the selection. The view is keyed by
|
||||
// route path, so it serves one controller.
|
||||
// route path, so it serves one controller. Every query the list loads is also
|
||||
// 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.
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
@@ -112,9 +116,14 @@ function rowLink(row: AdminRecord): string | null {
|
||||
}
|
||||
|
||||
function replaceQuery(next: ListQuery): void {
|
||||
// Stored first, so clearing every control stores the empty query and the
|
||||
// following run loads the unfiltered list instead of restoring.
|
||||
writeListState(controllerId, next)
|
||||
void router.replace({ path: route.path, query: toListQuery(next) })
|
||||
}
|
||||
|
||||
const EMPTY_QUERY_KEY = listQueryKey({})
|
||||
|
||||
let generation = 0
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
@@ -129,6 +138,16 @@ async function loadList(): Promise<void> {
|
||||
meta.value = result.data?.meta ?? null
|
||||
listFailed.value = !result.data
|
||||
loading.value = false
|
||||
if (result.response?.status === 422) {
|
||||
// A stale stored filter or sort never sticks.
|
||||
writeListState(controllerId, {})
|
||||
return
|
||||
}
|
||||
const lastPage = meta.value?.last_page ?? 0
|
||||
if (lastPage >= 1 && (meta.value?.page ?? 0) > lastPage) {
|
||||
// Records were removed since the query was stored or shared.
|
||||
replaceQuery({ ...query.value, page: lastPage })
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -145,7 +164,18 @@ void load()
|
||||
|
||||
watch(
|
||||
() => listQueryKey(query.value),
|
||||
() => {
|
||||
(key) => {
|
||||
// Entered without list params (navigation, breadcrumb, a form's back,
|
||||
// cancel, save or delete, or the same list's link while it is filtered):
|
||||
// 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) {
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
return
|
||||
}
|
||||
}
|
||||
writeListState(controllerId, query.value)
|
||||
selected.value = []
|
||||
searchText.value = query.value.search ?? ''
|
||||
void loadList()
|
||||
|
||||
Reference in New Issue
Block a user