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.
}
}

View File

@@ -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([])
}

View File

@@ -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'

View File

@@ -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()

View File

@@ -3,6 +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 { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import {
clone,
@@ -492,3 +493,25 @@ describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
})
describe('list state in the URL and browser storage (D-01)', () => {
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())
await flushPromises()
const [first] = requestsTo(calls, 'GET', BASE)
const query = queryOf(first!)
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('filter[active]')).toBe('true')
expect(router.currentRoute.value.query).toMatchObject({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
})
it('lets a URL with list params win and stores its query', async () => {
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc' })
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes())
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBeNull()
expect(readListState('acme.demo.widgets')).toEqual({ search: 'blue' })
})
})

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { parseListQuery, toListQuery } from '../../src/app/listQuery'
import { clearListStates } from '../../src/state/listState'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
@@ -39,6 +40,8 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
beforeEach(() => {
clearUser()
// Lists remember their query (D-01); every test starts from a clean list.
clearListStates()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-D3quaPLf.js"></script>
<script type="module" crossorigin src="./assets/index-Dm9E9btv.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
</head>
<body>

View File

@@ -367,10 +367,11 @@ hygiene_checks() {
[[ -z "$hits" ]] || fail "admin/src/api/types.ts declares a shape instead of aliasing the generated schema: $hits"
hits="$(cd "$tree" && grep -nE '^export type [A-Za-z]+ = ' admin/src/api/types.ts | grep -vE "= (Schemas\['cabana\.[A-Za-z_-]+'\]|NonNullable<[A-Za-z]+Path\['get'\]\['parameters'\]\['query'\]>|[A-Za-z]+Path\['get'\]\['parameters'\]\['path'\])\$" || true)"
[[ -z "$hits" ]] || fail "admin/src/api/types.ts alias not onto the generated schema: $hits"
# Browser storage holds only the sidebar flag (T-10-22), never a token.
# Browser storage holds only the sidebar flag (T-10-22) and the per-list
# query parameters (T-16.1-03), never a token.
hits="$(cd "$tree" && grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src 2>/dev/null |
grep -v '^admin/src/state/useSidebar.ts:' || true)"
[[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts: $hits"
grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:' || true)"
[[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts and admin/src/state/listState.ts: $hits"
# The embedded build loads nothing from another origin.
hits="$(cd "$tree" && grep -noE '(src|href)="(https?:)?//[^"]*"|url\((["'"'"']?)(https?:)?//' modules/boardwalk/dist/index.html modules/boardwalk/dist/assets/*.css 2>/dev/null || true)"
[[ -z "$hits" ]] || fail "modules/boardwalk/dist references another origin: $hits"