@quazardous/qdadm 2.19.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +106 -18
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.registries.ts +2 -2
- package/src/kernel/Kernel.types.ts +16 -0
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test": "vitest run",
|
|
20
20
|
"test:watch": "vitest",
|
|
21
21
|
"gen:i18n-defaults": "node scripts/gen-i18n-defaults.mjs",
|
|
22
|
-
"type-check": "vue-tsc --noEmit",
|
|
22
|
+
"type-check": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.typetest.json",
|
|
23
23
|
"lint": "eslint src/",
|
|
24
24
|
"lint:fix": "eslint src/ --fix",
|
|
25
25
|
"format": "prettier --write src/",
|
|
@@ -104,6 +104,12 @@ const props = defineProps({
|
|
|
104
104
|
rows: { type: Number, default: 10 },
|
|
105
105
|
rowsPerPageOptions: { type: Array as PropType<number[]>, default: () => [10, 50, 100] },
|
|
106
106
|
totalRecords: { type: Number, default: 0 },
|
|
107
|
+
/**
|
|
108
|
+
* Row offset of the page being shown — what positions the paginator (#2145).
|
|
109
|
+
* Required in lazy mode: the table is handed one page of rows and cannot
|
|
110
|
+
* infer which page they are.
|
|
111
|
+
*/
|
|
112
|
+
first: { type: Number, default: 0 },
|
|
107
113
|
lazy: { type: Boolean, default: false },
|
|
108
114
|
|
|
109
115
|
// Sorting
|
|
@@ -438,6 +444,7 @@ function getFilterClass(filter: FilterConfig): Record<string, boolean> {
|
|
|
438
444
|
:rows="rows"
|
|
439
445
|
:rowsPerPageOptions="rowsPerPageOptions"
|
|
440
446
|
:totalRecords="totalRecords"
|
|
447
|
+
:first="first"
|
|
441
448
|
:lazy="lazy"
|
|
442
449
|
:sortField="sortField ?? undefined"
|
|
443
450
|
:sortOrder="sortOrder"
|
package/src/composables/index.ts
CHANGED
|
@@ -30,10 +30,13 @@ export * from './useJsonSyntax'
|
|
|
30
30
|
export {
|
|
31
31
|
useListPage,
|
|
32
32
|
PAGE_SIZE_OPTIONS,
|
|
33
|
+
FILTER_TYPES,
|
|
33
34
|
type UseListPageOptions,
|
|
34
35
|
type UseListPageReturn,
|
|
35
36
|
type ColumnConfig,
|
|
36
37
|
type FilterConfig,
|
|
38
|
+
type FilterOptions,
|
|
39
|
+
type FilterType,
|
|
37
40
|
type ActionConfig as ListActionConfig,
|
|
38
41
|
type ResolvedAction,
|
|
39
42
|
type HeaderActionConfig,
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* useListPage composes it back in; behavior is unchanged.
|
|
8
8
|
*/
|
|
9
9
|
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
|
10
|
-
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
|
11
10
|
import { FilterQuery, type QueryOrchestratorLike } from '../query/FilterQuery'
|
|
12
|
-
import
|
|
11
|
+
import { FILTER_TYPES } from './useListPage.types'
|
|
12
|
+
import type { FilterConfig, FilterOptions, SearchConfig } from './useListPage.types'
|
|
13
|
+
import type { RouteStatePersister } from '../routeState'
|
|
13
14
|
import {
|
|
14
15
|
SMART_FILTER_THRESHOLD,
|
|
15
16
|
clearSessionFilters,
|
|
@@ -29,14 +30,38 @@ export interface UseListFiltersDeps {
|
|
|
29
30
|
items: Ref<unknown[]>
|
|
30
31
|
/** Current page ref — filter changes reset it to 1. */
|
|
31
32
|
page: Ref<number>
|
|
33
|
+
/** Rows per page — persisted alongside the rest (#2146). */
|
|
34
|
+
pageSize: Ref<number>
|
|
35
|
+
/** Active sort field, persisted alongside the rest (#2146). */
|
|
36
|
+
sortField: Ref<string | null>
|
|
37
|
+
/** Active sort direction. */
|
|
38
|
+
sortOrder: Ref<number>
|
|
39
|
+
/**
|
|
40
|
+
* Whether the sort is remembered at all.
|
|
41
|
+
*
|
|
42
|
+
* Its own flag rather than folded into `routeStateWrites`, because it has
|
|
43
|
+
* always been separately switchable and a seam refactor must not quietly
|
|
44
|
+
* take an option away.
|
|
45
|
+
*/
|
|
46
|
+
persistSort: boolean
|
|
32
47
|
/** Search query ref (shared with the search subsystem). */
|
|
33
48
|
searchQuery: Ref<string>
|
|
34
|
-
route: RouteLocationNormalizedLoaded
|
|
35
|
-
router: Router
|
|
36
49
|
/** Session-restored filter values (already stripped of _search). */
|
|
37
50
|
savedFilters: Record<string, unknown> | null
|
|
38
51
|
persistFilters: boolean
|
|
39
|
-
|
|
52
|
+
/** Where this list's state is remembered (#2146). */
|
|
53
|
+
persister: RouteStatePersister | null
|
|
54
|
+
/** This list's namespace, so two lists on one route stop colliding. */
|
|
55
|
+
routeStateScope: string
|
|
56
|
+
/**
|
|
57
|
+
* Whether this list may WRITE its state back.
|
|
58
|
+
*
|
|
59
|
+
* Separate from having a persister because reading and writing were always
|
|
60
|
+
* separate here: `syncUrlParams: false` stopped the query string being
|
|
61
|
+
* written and never stopped it being read, so a hand-typed deep link still
|
|
62
|
+
* restored. Collapsing the two would have changed that quietly.
|
|
63
|
+
*/
|
|
64
|
+
routeStateWrites: boolean
|
|
40
65
|
autoLoadFilters: boolean
|
|
41
66
|
filterSessionKey: string
|
|
42
67
|
/** Entity filters registry (injected by the consuming app). */
|
|
@@ -51,7 +76,7 @@ export interface UseListFiltersReturn {
|
|
|
51
76
|
filterValues: Ref<Record<string, unknown>>
|
|
52
77
|
filters: ComputedRef<FilterConfig[]>
|
|
53
78
|
hasActiveFilters: ComputedRef<boolean>
|
|
54
|
-
addFilter: (name: string, filterConfig:
|
|
79
|
+
addFilter: (name: string, filterConfig: FilterOptions) => void
|
|
55
80
|
removeFilter: (name: string) => void
|
|
56
81
|
setFilterValue: (name: string, value: unknown) => void
|
|
57
82
|
updateFilters: (newValues: Record<string, unknown>) => void
|
|
@@ -74,11 +99,15 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
74
99
|
items,
|
|
75
100
|
page,
|
|
76
101
|
searchQuery,
|
|
77
|
-
route,
|
|
78
|
-
router,
|
|
79
102
|
savedFilters,
|
|
80
103
|
persistFilters,
|
|
81
|
-
|
|
104
|
+
persister,
|
|
105
|
+
routeStateScope,
|
|
106
|
+
routeStateWrites,
|
|
107
|
+
pageSize,
|
|
108
|
+
sortField,
|
|
109
|
+
sortOrder,
|
|
110
|
+
persistSort,
|
|
82
111
|
autoLoadFilters,
|
|
83
112
|
filterSessionKey,
|
|
84
113
|
entityFilters,
|
|
@@ -89,16 +118,21 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
89
118
|
const filtersMap = ref<Map<string, FilterConfig>>(new Map())
|
|
90
119
|
const filterValues = ref<Record<string, unknown>>(savedFilters || {})
|
|
91
120
|
|
|
92
|
-
/** Names the
|
|
121
|
+
/** Names the route state already owns — a filter cannot have them (#2113). */
|
|
93
122
|
const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
|
|
94
123
|
const warnedReserved = new Set<string>()
|
|
95
124
|
|
|
96
125
|
/**
|
|
97
|
-
* A filter named `page` or `search` silently overwrites the
|
|
126
|
+
* A filter named `page` or `search` silently overwrites the stored key of
|
|
98
127
|
* the same name, and is overwritten back on restore. `search` has behaved
|
|
99
128
|
* this way since the URL sync existed and nobody ever reported it, which
|
|
100
|
-
* says how quiet the failure is; `page`
|
|
101
|
-
*
|
|
129
|
+
* says how quiet the failure is; `page` joined it once the page number
|
|
130
|
+
* started being remembered too.
|
|
131
|
+
*
|
|
132
|
+
* SCOPING DID NOT FIX THIS. Prefixing keys by entity separates this list
|
|
133
|
+
* from other lists; it does not separate the list from qdadm's own two
|
|
134
|
+
* keys, which live in the same scope it does — `offers.page` is written by
|
|
135
|
+
* the pager whatever a filter called `page` wants.
|
|
102
136
|
*
|
|
103
137
|
* We name what happens INSTEAD of what was asked, rather than dropping the
|
|
104
138
|
* filter — renaming someone's filter behind their back would be a worse
|
|
@@ -106,20 +140,53 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
106
140
|
*/
|
|
107
141
|
function warnIfReservedName(name: string): void {
|
|
108
142
|
if (!RESERVED_QUERY_KEYS.has(name)) return
|
|
109
|
-
|
|
143
|
+
// Gated on writing, not on the URL specifically: the collision is with
|
|
144
|
+
// whatever medium this list persists to.
|
|
145
|
+
if (!routeStateWrites) return
|
|
110
146
|
if (warnedReserved.has(name)) return
|
|
111
147
|
warnedReserved.add(name)
|
|
112
148
|
console.warn(
|
|
113
|
-
`[qdadm] Filter "${name}" on "${entityName}" collides with the
|
|
114
|
-
`
|
|
149
|
+
`[qdadm] Filter "${name}" on "${entityName}" collides with the key of ` +
|
|
150
|
+
`the same name in this list's persisted route state. The list's own ` +
|
|
115
151
|
`${name === 'page' ? 'page number' : 'search query'} wins and this ` +
|
|
116
|
-
`filter will not survive a reload. Rename the filter, or
|
|
117
|
-
`
|
|
152
|
+
`filter will not survive a reload. Rename the filter, or turn the ` +
|
|
153
|
+
`persistence off on this list with syncUrlParams: false.`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const warnedTypes = new Set<string>()
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A filter type qdadm cannot render (#2147).
|
|
161
|
+
*
|
|
162
|
+
* `ListPage` is binary — an autocomplete, or a `Select` for everything
|
|
163
|
+
* else, unknown types included. So an invented type does not fail; it
|
|
164
|
+
* renders as a dropdown with no options, reading "No available options",
|
|
165
|
+
* and the user concludes there is nothing to choose. Reported by a consumer
|
|
166
|
+
* whose two `type: 'text'` filters had never filtered anything.
|
|
167
|
+
*
|
|
168
|
+
* Warned HERE because this is the last place the filter's NAME is known.
|
|
169
|
+
* Three screens later there is only an empty control and no way back to
|
|
170
|
+
* the declaration that caused it.
|
|
171
|
+
*/
|
|
172
|
+
function warnIfUnknownType(name: string, type: unknown): void {
|
|
173
|
+
if (type === undefined || type === null) return
|
|
174
|
+
if (FILTER_TYPES.includes(type as (typeof FILTER_TYPES)[number])) return
|
|
175
|
+
const key = `${name}:${String(type)}`
|
|
176
|
+
if (warnedTypes.has(key)) return
|
|
177
|
+
warnedTypes.add(key)
|
|
178
|
+
console.warn(
|
|
179
|
+
`[qdadm] Filter "${name}" on "${entityName}" has type "${String(type)}", ` +
|
|
180
|
+
`which qdadm cannot render. It will fall back to a dropdown, and ` +
|
|
181
|
+
`unless you also give it options that dropdown will be empty — ` +
|
|
182
|
+
`"No available options" — rather than the control you asked for. ` +
|
|
183
|
+
`Known types: ${FILTER_TYPES.join(', ')}.`
|
|
118
184
|
)
|
|
119
185
|
}
|
|
120
186
|
|
|
121
|
-
function addFilter(name: string, filterConfig:
|
|
187
|
+
function addFilter(name: string, filterConfig: FilterOptions): void {
|
|
122
188
|
warnIfReservedName(name)
|
|
189
|
+
warnIfUnknownType(name, filterConfig?.type)
|
|
123
190
|
filtersMap.value.set(name, {
|
|
124
191
|
name,
|
|
125
192
|
type: 'select',
|
|
@@ -155,34 +222,37 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
155
222
|
* for a detail view, so that coming back restores it.
|
|
156
223
|
*/
|
|
157
224
|
function writeStateToUrl(): void {
|
|
158
|
-
if (!
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
225
|
+
if (!persister || !routeStateWrites) return
|
|
226
|
+
|
|
227
|
+
// Page 1 is the default and stays implicit, exactly like an unset filter:
|
|
228
|
+
// the persister drops null/undefined/'' rather than storing them empty,
|
|
229
|
+
// so a pristine list leaves a clean link.
|
|
230
|
+
//
|
|
231
|
+
// `search` and `page` still share the namespace with the filter names —
|
|
232
|
+
// the scope separates this list from OTHER lists, not from qdadm's own
|
|
233
|
+
// keys — which is why addFilter() still warns about a filter called
|
|
234
|
+
// `page` or `search`.
|
|
235
|
+
persister.write(routeStateScope, {
|
|
236
|
+
...filterValues.value,
|
|
237
|
+
search: searchQuery.value || null,
|
|
238
|
+
page: page.value > 1 ? page.value : null,
|
|
239
|
+
// Always written, never conditional on a default: rows-per-page is a
|
|
240
|
+
// choice somebody made, and the default is not "unset" — it is what
|
|
241
|
+
// they get back if we forget. The default composition routes it to a
|
|
242
|
+
// cookie, so it does not land in a shareable link.
|
|
243
|
+
pageSize: pageSize.value,
|
|
244
|
+
// Sort goes to the session under the default composition, so it does
|
|
245
|
+
// not reach a link either. `persistSort: false` writes null, which the
|
|
246
|
+
// persister removes — the option has always been able to turn this off
|
|
247
|
+
// and still can.
|
|
248
|
+
//
|
|
249
|
+
// The DIRECTION is stored only when there is a field to apply it to.
|
|
250
|
+
// An order on its own is not partial state, it is meaningless state,
|
|
251
|
+
// and storing it would leave `sortOrder: -1` sitting in every unsorted
|
|
252
|
+
// list's namespace.
|
|
253
|
+
sort: persistSort ? sortField.value : null,
|
|
254
|
+
sortOrder: persistSort && sortField.value ? sortOrder.value : null,
|
|
255
|
+
})
|
|
186
256
|
}
|
|
187
257
|
|
|
188
258
|
function onFiltersChanged(): void {
|
|
@@ -220,15 +290,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
220
290
|
clearSessionFilters(filterSessionKey)
|
|
221
291
|
}
|
|
222
292
|
page.value = 1
|
|
223
|
-
if (
|
|
224
|
-
const query = { ...route.query } as Record<string, string>
|
|
225
|
-
for (const key of filtersMap.value.keys()) {
|
|
226
|
-
delete query[key]
|
|
227
|
-
}
|
|
228
|
-
delete query.search
|
|
229
|
-
delete query.page
|
|
230
|
-
router.replace({ query })
|
|
231
|
-
}
|
|
293
|
+
if (routeStateWrites) persister?.clear(routeStateScope)
|
|
232
294
|
loadItems()
|
|
233
295
|
}
|
|
234
296
|
|
|
@@ -472,23 +534,31 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
472
534
|
}
|
|
473
535
|
|
|
474
536
|
function restoreFilters(): void {
|
|
537
|
+
const stored = persister?.read(routeStateScope)
|
|
538
|
+
if (!stored) return
|
|
539
|
+
|
|
540
|
+
// Only keys this list declares as filters: the scope keeps other lists
|
|
541
|
+
// out, but a persister may still hand back something the app put there.
|
|
475
542
|
for (const key of filtersMap.value.keys()) {
|
|
476
|
-
if (
|
|
477
|
-
let value: unknown = route.query[key]
|
|
478
|
-
if (value === 'true') value = true
|
|
479
|
-
else if (value === 'false') value = false
|
|
480
|
-
else if (value === 'null') value = null
|
|
481
|
-
else if (!isNaN(Number(value)) && value !== '') value = Number(value)
|
|
482
|
-
filterValues.value[key] = value
|
|
483
|
-
}
|
|
543
|
+
if (stored[key] !== undefined) filterValues.value[key] = stored[key]
|
|
484
544
|
}
|
|
485
|
-
|
|
486
|
-
|
|
545
|
+
|
|
546
|
+
// NOT `typeof === 'string'` (#2147). A term that survives a numeric round
|
|
547
|
+
// trip — an order id, an invoice, a reference — comes back from the URL
|
|
548
|
+
// as a number, and a string-only guard dropped it: the box came back
|
|
549
|
+
// empty and the list unfiltered, so a shared link showed the recipient
|
|
550
|
+
// something other than what the sender searched for.
|
|
551
|
+
//
|
|
552
|
+
// Safe to stringify because the persister no longer coerces lossily: a
|
|
553
|
+
// value that could not be written back as the same text was left as text.
|
|
554
|
+
if (stored.search !== null && stored.search !== undefined && stored.search !== '') {
|
|
555
|
+
searchQuery.value = String(stored.search)
|
|
487
556
|
}
|
|
557
|
+
|
|
488
558
|
// The page must be restored HERE, before the first loadItems(): restoring
|
|
489
559
|
// it later would mean a first request for page 1 and a second for the
|
|
490
560
|
// real one (#2113).
|
|
491
|
-
const restoredPage = Number(
|
|
561
|
+
const restoredPage = Number(stored.page)
|
|
492
562
|
if (Number.isInteger(restoredPage) && restoredPage > 0) {
|
|
493
563
|
page.value = restoredPage
|
|
494
564
|
}
|
|
@@ -29,6 +29,12 @@ import {
|
|
|
29
29
|
type Ref,
|
|
30
30
|
} from 'vue'
|
|
31
31
|
import { useRouter, useRoute, type RouteLocationRaw } from 'vue-router'
|
|
32
|
+
import {
|
|
33
|
+
createDefaultRouteStatePersister,
|
|
34
|
+
resolveRouteStatePersister,
|
|
35
|
+
createRouteStatePersisterFactory,
|
|
36
|
+
type RouteStatePersister,
|
|
37
|
+
} from '../routeState'
|
|
32
38
|
import { useConfirm } from 'primevue/useconfirm'
|
|
33
39
|
import { useHooks } from './useHooks.js'
|
|
34
40
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
@@ -68,6 +74,8 @@ import type {
|
|
|
68
74
|
export type {
|
|
69
75
|
ColumnConfig,
|
|
70
76
|
FilterConfig,
|
|
77
|
+
FilterOptions,
|
|
78
|
+
FilterType,
|
|
71
79
|
ActionConfig,
|
|
72
80
|
ResolvedAction,
|
|
73
81
|
HeaderActionConfig,
|
|
@@ -86,11 +94,11 @@ export type {
|
|
|
86
94
|
// Stateless utilities (cookies, session storage, formatters, constants).
|
|
87
95
|
import {
|
|
88
96
|
PAGE_SIZE_OPTIONS,
|
|
89
|
-
|
|
97
|
+
restorePageSize,
|
|
90
98
|
getSessionFilters,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
restoreSort,
|
|
100
|
+
clearSessionSort,
|
|
101
|
+
retireLegacyPageSizeCookie,
|
|
94
102
|
} from './useListPage.utils'
|
|
95
103
|
import { useActionRegistry } from './useActionRegistry'
|
|
96
104
|
import { useListFilters } from './useListPage.filters'
|
|
@@ -101,6 +109,9 @@ import { createOrchestratorToast } from './useOrchestratorToast'
|
|
|
101
109
|
// Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
|
|
102
110
|
// composables/index.ts) keep their import path.
|
|
103
111
|
export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
|
|
112
|
+
// A VALUE, not only a type: a consumer guard should read this list rather
|
|
113
|
+
// than copy it, so adding a type here never turns correct code red (#2147).
|
|
114
|
+
export { FILTER_TYPES } from './useListPage.types'
|
|
104
115
|
|
|
105
116
|
|
|
106
117
|
/**
|
|
@@ -120,6 +131,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
120
131
|
persistFilters = true,
|
|
121
132
|
persistSort = true,
|
|
122
133
|
syncUrlParams = true,
|
|
134
|
+
routeState = null,
|
|
123
135
|
autoLoadFilters = true,
|
|
124
136
|
onBeforeLoad = null,
|
|
125
137
|
onAfterLoad = null,
|
|
@@ -215,6 +227,41 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
215
227
|
// Session key for filter persistence (based on entity name)
|
|
216
228
|
const filterSessionKey = entity || entityName
|
|
217
229
|
|
|
230
|
+
// App-wide route-state default (#2146). Null when the kernel said nothing,
|
|
231
|
+
// or when there is no kernel at all — which is the case in tests.
|
|
232
|
+
const kernelRouteState = inject<string | RouteStatePersister | null>('qdadmRouteState', null)
|
|
233
|
+
|
|
234
|
+
// Where this list remembers what it is showing (#2146).
|
|
235
|
+
//
|
|
236
|
+
// WHO DECIDES, most specific first: this list, then the entity (so every
|
|
237
|
+
// screen showing it agrees), then the kernel, then the default composition.
|
|
238
|
+
// Each level exists because somebody has to be able to say it at that level
|
|
239
|
+
// — a list is a screen's decision, an entity's medium is a modelling one,
|
|
240
|
+
// and the app-wide floor belongs in the bootstrap.
|
|
241
|
+
//
|
|
242
|
+
// Built HERE, before the state refs, because `pageSize` is restored from it
|
|
243
|
+
// and the table needs a row count from its very first render.
|
|
244
|
+
const routeStateScope = entity || entityName
|
|
245
|
+
const chosenRouteState = routeState ?? manager.routeState ?? kernelRouteState
|
|
246
|
+
const persister: RouteStatePersister = chosenRouteState
|
|
247
|
+
? resolveRouteStatePersister(
|
|
248
|
+
chosenRouteState,
|
|
249
|
+
createRouteStatePersisterFactory({ router, route })
|
|
250
|
+
)
|
|
251
|
+
: createDefaultRouteStatePersister({ router, route })
|
|
252
|
+
|
|
253
|
+
// `syncUrlParams` governs WRITING, and only ever has: a list configured
|
|
254
|
+
// with `false` still restored from a query string typed by hand. Kept
|
|
255
|
+
// exactly, because moving this behind a seam must not change what any
|
|
256
|
+
// existing app does.
|
|
257
|
+
//
|
|
258
|
+
// Naming a persister is a deliberate choice about a medium, so it answers
|
|
259
|
+
// the URL flag rather than obeying it: `routeState` set means read AND
|
|
260
|
+
// write, whatever `syncUrlParams` says about the query string.
|
|
261
|
+
const routeStateWrites = chosenRouteState ? true : syncUrlParams
|
|
262
|
+
|
|
263
|
+
const storedRouteState = persister.read(routeStateScope)
|
|
264
|
+
|
|
218
265
|
// Entity filters registry (optional, provided by consuming app)
|
|
219
266
|
const entityFilters = inject<Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>>(
|
|
220
267
|
'qdadmEntityFilters',
|
|
@@ -238,15 +285,29 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
238
285
|
|
|
239
286
|
// Pagination
|
|
240
287
|
const page = ref(1)
|
|
241
|
-
|
|
288
|
+
// Rows per page comes through the seam now (#2146) — by default routed to
|
|
289
|
+
// the same cookie it has always used, because it is a comfort setting
|
|
290
|
+
// somebody chose once, not something a link should impose on its reader.
|
|
291
|
+
//
|
|
292
|
+
// The legacy bare-number cookie is read when the seam has nothing, so an
|
|
293
|
+
// existing user's choice survives the move; the first change retires it.
|
|
294
|
+
const pageSize = ref(restorePageSize(storedRouteState?.pageSize, defaultPageSize))
|
|
242
295
|
const totalRecords = ref(0)
|
|
243
296
|
const rowsPerPageOptions = PAGE_SIZE_OPTIONS
|
|
244
297
|
|
|
245
|
-
// Sorting —
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
298
|
+
// Sorting — through the seam now (#2146), and still landing in the session
|
|
299
|
+
// by default: the composition routes `sort` back to `sessionStorage` on
|
|
300
|
+
// purpose, so an ordering keeps surviving a clean `/offers` the way it
|
|
301
|
+
// always has. Only the key changed, which is why the legacy entry is read
|
|
302
|
+
// when the seam has nothing.
|
|
303
|
+
const savedSort = persistSort
|
|
304
|
+
? restoreSort(storedRouteState, filterSessionKey, {
|
|
305
|
+
field: defaultSort,
|
|
306
|
+
order: defaultSortOrder as 1 | -1,
|
|
307
|
+
})
|
|
308
|
+
: { field: defaultSort, order: defaultSortOrder as 1 | -1 }
|
|
309
|
+
const sortField = ref<string | null>(savedSort.field)
|
|
310
|
+
const sortOrder = ref<number>(savedSort.order)
|
|
250
311
|
|
|
251
312
|
// Search
|
|
252
313
|
const searchQuery = ref(savedSearch)
|
|
@@ -588,18 +649,38 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
588
649
|
// ============ FILTERS (#1195 — extracted subsystem) ============
|
|
589
650
|
// loadItems and setSearch are passed as thunks: both are declared further
|
|
590
651
|
// down and only ever run post-setup.
|
|
652
|
+
// Where this list remembers what it is showing (#2146).
|
|
653
|
+
//
|
|
654
|
+
// The scope is the entity, so two lists on one route stop colliding — the
|
|
655
|
+
// case page-compositions.md currently tells people to dodge by turning
|
|
656
|
+
// persistence off entirely.
|
|
657
|
+
//
|
|
658
|
+
// `syncUrlParams` governs WRITING, and only ever has: a list configured
|
|
659
|
+
// with `false` still restored from a query string typed by hand. Kept
|
|
660
|
+
// exactly, because moving this behind a seam must not change what any
|
|
661
|
+
// existing app does — the asymmetry is arguable, but arguing it belongs in
|
|
662
|
+
// its own change with its own changelog line.
|
|
663
|
+
//
|
|
664
|
+
// Naming a persister is a deliberate choice about a medium, so it answers
|
|
665
|
+
// the URL flag rather than obeying it: `routeState` set means read AND
|
|
666
|
+
// write, whatever `syncUrlParams` says about the query string.
|
|
667
|
+
|
|
591
668
|
const listFilters = useListFilters({
|
|
592
669
|
entityName,
|
|
670
|
+
persister,
|
|
671
|
+
routeStateScope,
|
|
672
|
+
routeStateWrites,
|
|
673
|
+
pageSize,
|
|
674
|
+
sortField,
|
|
675
|
+
sortOrder,
|
|
676
|
+
persistSort,
|
|
593
677
|
manager,
|
|
594
678
|
orchestrator,
|
|
595
679
|
items: items as Ref<unknown[]>,
|
|
596
680
|
page,
|
|
597
681
|
searchQuery,
|
|
598
|
-
route,
|
|
599
|
-
router,
|
|
600
682
|
savedFilters,
|
|
601
683
|
persistFilters,
|
|
602
|
-
syncUrlParams,
|
|
603
684
|
autoLoadFilters,
|
|
604
685
|
filterSessionKey,
|
|
605
686
|
entityFilters,
|
|
@@ -798,7 +879,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
798
879
|
function onPage(event: { page: number; rows: number }): void {
|
|
799
880
|
page.value = event.page + 1
|
|
800
881
|
pageSize.value = event.rows
|
|
801
|
-
|
|
882
|
+
// Written by writeStateToUrl() below, through the persister, rather than
|
|
883
|
+
// straight to a cookie from here (#2146).
|
|
884
|
+
retireLegacyPageSizeCookie()
|
|
802
885
|
// The page joins the URL (#2113) — it was the only piece of list state
|
|
803
886
|
// that survived nothing, so leaving a list for a detail view and coming
|
|
804
887
|
// back dropped the user on page 1.
|
|
@@ -818,11 +901,12 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
818
901
|
sortField.value = event.sortField
|
|
819
902
|
sortOrder.value = event.sortOrder as 1 | -1
|
|
820
903
|
}
|
|
904
|
+
// Written through the persister by writeStateToUrl(), not straight to
|
|
905
|
+
// the session from here (#2146). The legacy entry is retired at the same
|
|
906
|
+
// time, so it cannot shadow the seam on a later read.
|
|
821
907
|
if (persistSort) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
order: sortOrder.value as 1 | -1,
|
|
825
|
-
})
|
|
908
|
+
writeStateToUrl()
|
|
909
|
+
clearSessionSort(filterSessionKey)
|
|
826
910
|
}
|
|
827
911
|
loadItems()
|
|
828
912
|
}
|
|
@@ -1201,6 +1285,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1201
1285
|
lazy: true,
|
|
1202
1286
|
totalRecords: totalRecords.value,
|
|
1203
1287
|
rows: pageSize.value,
|
|
1288
|
+
// Where the paginator sits (#2145). Derived from `page` rather than held
|
|
1289
|
+
// separately, so the highlighted page and the rows on screen cannot
|
|
1290
|
+
// disagree — which is exactly what they did while this was unbound.
|
|
1291
|
+
first: (page.value - 1) * pageSize.value,
|
|
1204
1292
|
rowsPerPageOptions,
|
|
1205
1293
|
sortField: sortField.value,
|
|
1206
1294
|
sortOrder: sortOrder.value,
|