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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -167,6 +167,14 @@ The badge texts are list messages. Override them per list in the `messages` bloc
|
||||
|
||||
A list that shows soft-deleted records includes them in its scope, as `withTrashed()` does in WinterCMS: return `db.Unscoped()` (narrowed as needed) from `pact.ListExtendQuery` and `pact.FormExtendQuery`. Such a record can then be shown, updated and targeted by bulk and record actions, and it stays soft-deleted through an update. Code that writes to it, such as an action's `Run`, must use an unscoped handle too (`tx.Unscoped()`), or GORM adds its `deleted_at IS NULL` condition and the write matches nothing. Deleting it through the admin soft-deletes again, which changes nothing; a controller that wants the delete to be permanent removes the row in `pact.FormAfterDelete`.
|
||||
|
||||
## Remembered list state
|
||||
|
||||
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.
|
||||
|
||||
Search terms can contain personal data, so remembered queries belong to one administrator and one session. They are stored under the signed-in administrator's id. Signing in as another administrator on the same browser removes every other administrator's queries, and the remembered queries of older builds, which were not stored per administrator. Every session end removes all of them: logging out, an expired session, and a 401 that the session refresh cannot recover. Only these list parameters are stored, never a token or record data.
|
||||
|
||||
A restored query never leaves a list unusable. If the list request built from a restored query fails for any reason (a 422 for a column or filter that no longer exists, a server error, or no connection), the admin forgets the query and loads the plain list once instead. A query that came from the URL keeps the error on screen, because the administrator chose it. A 422 still removes it from storage, and a button next to the error clears search, filters and sorting and loads the plain list.
|
||||
|
||||
## Scoping every list
|
||||
|
||||
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.
|
||||
|
||||
9
modules/boardwalk/dist/assets/index-CNLOPBsM.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-CNLOPBsM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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-Dm9E9btv.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CNLOPBsM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -40,6 +40,7 @@ list:
|
||||
other: ":count results"
|
||||
empty_value: "—"
|
||||
clear_search: Clear search
|
||||
reset_list: Reset search, filters and sorting
|
||||
select_all: Select all on this page
|
||||
select_row: Select row
|
||||
sort_ascending: Sort ascending
|
||||
|
||||
@@ -44,6 +44,7 @@ list:
|
||||
other: ":count wyniku"
|
||||
empty_value: "—"
|
||||
clear_search: Wyczyść wyszukiwanie
|
||||
reset_list: Wyczyść wyszukiwanie, filtry i sortowanie
|
||||
select_all: Zaznacz wszystkie na stronie
|
||||
select_row: Zaznacz wiersz
|
||||
sort_ascending: Sortuj rosnąco
|
||||
|
||||
Reference in New Issue
Block a user