@quazardous/qdadm 2.18.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 +5 -7
- package/src/components/editors/VanillaJsonEditor.vue +14 -4
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useLayoutResolver.ts +10 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +148 -26
- 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/gen/createManagers.ts +15 -1
- package/src/gen/index.ts +7 -1
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.registries.ts +202 -46
- package/src/kernel/Kernel.ts +34 -9
- package/src/kernel/Kernel.types.ts +19 -0
- package/src/kernel/Kernel.vue.ts +7 -0
- package/src/kernel/KernelContext.routing.ts +18 -7
- 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
|
@@ -28,7 +28,13 @@ import {
|
|
|
28
28
|
provide,
|
|
29
29
|
type Ref,
|
|
30
30
|
} from 'vue'
|
|
31
|
-
import { useRouter, useRoute } from 'vue-router'
|
|
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
|
}
|
|
@@ -855,21 +939,55 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
855
939
|
return { name: `${routePrefix}-create` }
|
|
856
940
|
}
|
|
857
941
|
|
|
942
|
+
/** Route names already reported as missing — one warning each. */
|
|
943
|
+
const warnedMissingRoutes = new Set<string>()
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Navigate by route name, and say something when that name does not exist
|
|
947
|
+
* (#1923 lot 2).
|
|
948
|
+
*
|
|
949
|
+
* The failure this catches is quiet by nature: the route prefix is derived
|
|
950
|
+
* from the entity name, so a mismatch between the side that NAMES routes
|
|
951
|
+
* and the side that LOOKS THEM UP produces a push to a name nobody
|
|
952
|
+
* registered. Nothing happens, and nothing says why — the divergence that
|
|
953
|
+
* caused #1923 behaved exactly like this, invisible until someone clicked.
|
|
954
|
+
*
|
|
955
|
+
* We warn at the moment of use rather than auditing the prefix upfront: an
|
|
956
|
+
* entity that only ever appears as a child of another has no routes under
|
|
957
|
+
* its own prefix and is perfectly healthy, so an upfront check would cry
|
|
958
|
+
* wolf. A warning nobody trusts is worse than none (ADR 0011).
|
|
959
|
+
*/
|
|
960
|
+
function pushNamedRoute(name: string, params?: Record<string, unknown>): void {
|
|
961
|
+
if (!router.hasRoute(name)) {
|
|
962
|
+
if (!warnedMissingRoutes.has(name)) {
|
|
963
|
+
warnedMissingRoutes.add(name)
|
|
964
|
+
console.warn(
|
|
965
|
+
`[qdadm] No route named "${name}" — this navigation does nothing. ` +
|
|
966
|
+
`The list for "${entity}" derives it from the route prefix ` +
|
|
967
|
+
`"${routePrefix}". Either that page is not declared, or the ` +
|
|
968
|
+
`prefix disagrees with the one its routes were registered under ` +
|
|
969
|
+
`(pass routePrefix on the entity, or on useListPage, to pin it).`
|
|
970
|
+
)
|
|
971
|
+
}
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
router.push({ name, ...(params ? { params } : {}) } as RouteLocationRaw)
|
|
975
|
+
}
|
|
976
|
+
|
|
858
977
|
function goToCreate(): void {
|
|
859
|
-
|
|
978
|
+
const target = findCreateRoute() as { name: string; params?: Record<string, unknown> }
|
|
979
|
+
pushNamedRoute(target.name, target.params)
|
|
860
980
|
}
|
|
861
981
|
|
|
862
982
|
function goToEdit(item: unknown): void {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
983
|
+
pushNamedRoute(`${routePrefix}-edit`, {
|
|
984
|
+
[manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
|
|
866
985
|
})
|
|
867
986
|
}
|
|
868
987
|
|
|
869
988
|
function goToShow(item: unknown): void {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
989
|
+
pushNamedRoute(`${routePrefix}-show`, {
|
|
990
|
+
[manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
|
|
873
991
|
})
|
|
874
992
|
}
|
|
875
993
|
|
|
@@ -1167,6 +1285,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1167
1285
|
lazy: true,
|
|
1168
1286
|
totalRecords: totalRecords.value,
|
|
1169
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,
|
|
1170
1292
|
rowsPerPageOptions,
|
|
1171
1293
|
sortField: sortField.value,
|
|
1172
1294
|
sortOrder: sortOrder.value,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from useListPage.ts for maintainability.
|
|
5
5
|
*/
|
|
6
|
+
import type { RouteStatePersister } from '../routeState'
|
|
6
7
|
import type { Ref, ComputedRef } from 'vue'
|
|
7
8
|
import type { Router } from 'vue-router'
|
|
8
9
|
import type { FilterQuery } from '../query/FilterQuery'
|
|
@@ -76,9 +77,45 @@ export interface ColumnConfig {
|
|
|
76
77
|
/**
|
|
77
78
|
* Filter configuration
|
|
78
79
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The filter controls qdadm knows how to render.
|
|
82
|
+
*
|
|
83
|
+
* Exported as a VALUE, not only a type, so a consumer's own guard can read
|
|
84
|
+
* this list instead of copying it — a copy goes stale the day a type is
|
|
85
|
+
* added and starts rejecting correct code.
|
|
86
|
+
*/
|
|
87
|
+
export const FILTER_TYPES = [
|
|
88
|
+
'select',
|
|
89
|
+
'multiselect',
|
|
90
|
+
'date',
|
|
91
|
+
'checkbox',
|
|
92
|
+
'dropdown',
|
|
93
|
+
'autocomplete',
|
|
94
|
+
] as const
|
|
95
|
+
|
|
96
|
+
export type FilterType = (typeof FILTER_TYPES)[number]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Everything a filter can be given EXCEPT its name (#2147).
|
|
100
|
+
*
|
|
101
|
+
* Split out rather than written as `Omit<FilterConfig, 'name'>`, and the
|
|
102
|
+
* reason is not style. `FilterConfig` carries an index signature, so
|
|
103
|
+
* `keyof` includes `string`; `Exclude<string, 'name'>` is still `string`, and
|
|
104
|
+
* the `Omit` therefore collapsed to a bare `{ [x: string]: unknown }` —
|
|
105
|
+
* ERASING every declared property.
|
|
106
|
+
*
|
|
107
|
+
* `addFilter` took that type, so **no filter option was checked at all**:
|
|
108
|
+
* not `type`, not `optionLabel`, not `local_filter`. A consumer declared
|
|
109
|
+
* `type: 'text'`, TypeScript accepted it, and the filter rendered as an empty
|
|
110
|
+
* dropdown saying "No available options". Nothing was wrong at any single
|
|
111
|
+
* step, which is why it survived.
|
|
112
|
+
*
|
|
113
|
+
* Declaring the properties here and having `FilterConfig` extend it keeps the
|
|
114
|
+
* index signature — apps do stash their own keys — while the declared ones
|
|
115
|
+
* are checked again.
|
|
116
|
+
*/
|
|
117
|
+
export interface FilterOptions {
|
|
118
|
+
type?: FilterType
|
|
82
119
|
placeholder?: string
|
|
83
120
|
options?: Array<{ label: string; value: unknown }>
|
|
84
121
|
default?: unknown
|
|
@@ -101,6 +138,10 @@ export interface FilterConfig {
|
|
|
101
138
|
[key: string]: unknown
|
|
102
139
|
}
|
|
103
140
|
|
|
141
|
+
export interface FilterConfig extends FilterOptions {
|
|
142
|
+
name: string
|
|
143
|
+
}
|
|
144
|
+
|
|
104
145
|
/**
|
|
105
146
|
* Row action configuration
|
|
106
147
|
*/
|
|
@@ -229,8 +270,27 @@ export interface UseListPageOptions<T = unknown> {
|
|
|
229
270
|
persistFilters?: boolean
|
|
230
271
|
/** Persist the active sort per entity (session storage), restored on init (#1218). Default true. */
|
|
231
272
|
persistSort?: boolean
|
|
232
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* WRITE filters, search and the page to the URL query (default: true).
|
|
275
|
+
*
|
|
276
|
+
* Writing only. `false` has never stopped the query string being read, so
|
|
277
|
+
* a deep link someone typed by hand still restores the list — say
|
|
278
|
+
* `routeState` if you want the state kept somewhere else entirely.
|
|
279
|
+
*/
|
|
233
280
|
syncUrlParams?: boolean
|
|
281
|
+
/**
|
|
282
|
+
* Where this list's state is remembered (#2146): a slug the framework
|
|
283
|
+
* knows (`'url'`, …), or your own persister. Defaults to the URL.
|
|
284
|
+
*
|
|
285
|
+
* Setting it is a deliberate choice of medium, so it answers
|
|
286
|
+
* `syncUrlParams` rather than obeying it: a list with `routeState` set
|
|
287
|
+
* reads and writes that medium whatever the URL flag says.
|
|
288
|
+
*
|
|
289
|
+
* An unknown slug throws rather than falling back to the URL — a list
|
|
290
|
+
* quietly persisting somewhere other than where you asked is worse than
|
|
291
|
+
* one that will not start (ADR 0011).
|
|
292
|
+
*/
|
|
293
|
+
routeState?: string | RouteStatePersister
|
|
234
294
|
/** Filter mode: 'auto' | 'manager' | 'local' */
|
|
235
295
|
filterMode?: 'auto' | 'manager' | 'local'
|
|
236
296
|
/** Auto-filter threshold for 'auto' mode */
|
|
@@ -270,6 +330,15 @@ export interface ListPageProps {
|
|
|
270
330
|
lazy: boolean
|
|
271
331
|
totalRecords: number
|
|
272
332
|
rows: number
|
|
333
|
+
/**
|
|
334
|
+
* Row offset of the current page — what POSITIONS the paginator (#2145).
|
|
335
|
+
*
|
|
336
|
+
* In lazy mode the table renders exactly the rows it is handed and has no
|
|
337
|
+
* way to know which page they are; `first` is the only thing that tells it.
|
|
338
|
+
* Without it, a restored page 2 showed page 2's rows under a paginator
|
|
339
|
+
* highlighting 1.
|
|
340
|
+
*/
|
|
341
|
+
first: number
|
|
273
342
|
rowsPerPageOptions: number[]
|
|
274
343
|
sortField: string | null
|
|
275
344
|
sortOrder: number
|
|
@@ -382,7 +451,7 @@ export interface UseListPageReturn<T = unknown> {
|
|
|
382
451
|
filterValues: Ref<Record<string, unknown>>
|
|
383
452
|
filteredItems: ComputedRef<T[]>
|
|
384
453
|
fromCache: Ref<boolean>
|
|
385
|
-
addFilter: (name: string, config:
|
|
454
|
+
addFilter: (name: string, config: FilterOptions) => void
|
|
386
455
|
removeFilter: (name: string) => void
|
|
387
456
|
setFilterValue: (name: string, value: unknown) => void
|
|
388
457
|
updateFilters: (values: Record<string, unknown>) => void
|
|
@@ -40,6 +40,35 @@ export function persistPageSize(size: number): void {
|
|
|
40
40
|
setCookie(COOKIE_NAME, size, COOKIE_EXPIRY_DAYS)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Settle the rows-per-page a list opens on (#2146).
|
|
45
|
+
*
|
|
46
|
+
* The seam owns this now, so the stored value arrives from whichever medium
|
|
47
|
+
* the app chose. The LEGACY bare-number cookie is read only when the seam has
|
|
48
|
+
* nothing — an existing user's choice must survive the move, or a comfort
|
|
49
|
+
* setting they picked once quietly resets on upgrade.
|
|
50
|
+
*
|
|
51
|
+
* Anything outside {@link PAGE_SIZE_OPTIONS} falls back to the default: the
|
|
52
|
+
* value reaches us from a cookie or a query string that anyone can edit, and
|
|
53
|
+
* a row count the paginator cannot offer would leave its dropdown showing a
|
|
54
|
+
* blank.
|
|
55
|
+
*/
|
|
56
|
+
export function restorePageSize(stored: unknown, defaultSize: number): number {
|
|
57
|
+
const fromSeam = Number(stored)
|
|
58
|
+
if (PAGE_SIZE_OPTIONS.includes(fromSeam)) return fromSeam
|
|
59
|
+
return getSavedPageSize(defaultSize)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop the pre-seam cookie once its value has been carried over.
|
|
64
|
+
*
|
|
65
|
+
* Left in place it would outlive the setting it stands for by a year, and
|
|
66
|
+
* shadow the seam on any machine where the new store was cleared.
|
|
67
|
+
*/
|
|
68
|
+
export function retireLegacyPageSizeCookie(): void {
|
|
69
|
+
setCookie(COOKIE_NAME, '', -1)
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
73
|
// Default label formatter (snake_case → Title Case)
|
|
45
74
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -129,6 +158,35 @@ export function clearSessionSort(key: string): void {
|
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Settle the sort a list opens on (#2146).
|
|
163
|
+
*
|
|
164
|
+
* The seam owns this now. The medium has not changed — the default
|
|
165
|
+
* composition routes `sort` back to `sessionStorage`, deliberately, so that
|
|
166
|
+
* moving it behind an interface changes nothing anyone can observe. Only the
|
|
167
|
+
* KEY changed, from `qdadm_sort_offers` to `qdadm:offers:sort`.
|
|
168
|
+
*
|
|
169
|
+
* Which is why the legacy entry is still read when the seam has nothing: a
|
|
170
|
+
* sort somebody set five minutes ago must not vanish because the framework
|
|
171
|
+
* was upgraded under them.
|
|
172
|
+
*/
|
|
173
|
+
export function restoreSort(
|
|
174
|
+
stored: { sort?: unknown; sortOrder?: unknown } | null,
|
|
175
|
+
legacyKey: string,
|
|
176
|
+
defaults: SessionSort
|
|
177
|
+
): SessionSort {
|
|
178
|
+
const field = stored?.sort
|
|
179
|
+
const order = Number(stored?.sortOrder)
|
|
180
|
+
if (typeof field === 'string' && field && (order === 1 || order === -1)) {
|
|
181
|
+
return { field, order }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const legacy = getSessionSort(legacyKey)
|
|
185
|
+
if (legacy) return legacy
|
|
186
|
+
|
|
187
|
+
return defaults
|
|
188
|
+
}
|
|
189
|
+
|
|
132
190
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
191
|
// Smart filter discovery
|
|
134
192
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import type { EntityBadge } from './EntityManager.types'
|
|
28
28
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
29
29
|
import type { EntityAuthAdapter } from './auth/EntityAuthAdapter'
|
|
30
|
+
import type { RouteStatePersister } from '../routeState'
|
|
30
31
|
|
|
31
32
|
// ─── Base ────────────────────────────────────────────────────────────────────
|
|
32
33
|
|
|
@@ -84,6 +85,16 @@ export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, En
|
|
|
84
85
|
getFieldConfig: (name: string) => unknown | null
|
|
85
86
|
/** Config-carrying property: always present on the class, VALUE nullable */
|
|
86
87
|
localFilterThreshold?: number | null
|
|
88
|
+
/**
|
|
89
|
+
* Where lists of this entity remember their state (#2146): a slug the
|
|
90
|
+
* framework knows, or a persister instance.
|
|
91
|
+
*
|
|
92
|
+
* A property of the ENTITY, so every screen showing it agrees — a list of
|
|
93
|
+
* audit rows nobody links to can say `local_storage` once here rather than
|
|
94
|
+
* on each of the four pages that render it. A list's own `routeState`
|
|
95
|
+
* still wins; the kernel default applies when neither says anything.
|
|
96
|
+
*/
|
|
97
|
+
routeState?: string | RouteStatePersister
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -51,6 +51,7 @@ export type {
|
|
|
51
51
|
} from './EntityManager.types'
|
|
52
52
|
|
|
53
53
|
// Import concrete types needed in method signatures
|
|
54
|
+
import type { RouteStatePersister } from '../routeState'
|
|
54
55
|
import type {
|
|
55
56
|
RoutingContext,
|
|
56
57
|
PresaveContext,
|
|
@@ -88,6 +89,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
88
89
|
protected _fields: Record<string, FieldConfig>
|
|
89
90
|
|
|
90
91
|
localFilterThreshold: number | null
|
|
92
|
+
/** Where lists of this entity remember their state (#2146); null = inherit. */
|
|
93
|
+
routeState: string | RouteStatePersister | null
|
|
91
94
|
/** Default null placement for local sorts (#1222); field-level nullSort wins. */
|
|
92
95
|
nullSort?: NullSortMode
|
|
93
96
|
protected _cacheTtlMs: number | null
|
|
@@ -163,6 +166,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
163
166
|
badges = null,
|
|
164
167
|
fields = {},
|
|
165
168
|
localFilterThreshold = null,
|
|
169
|
+
routeState = null,
|
|
166
170
|
nullSort,
|
|
167
171
|
cacheTtlMs = null,
|
|
168
172
|
asymmetric = false,
|
|
@@ -196,6 +200,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
196
200
|
this._fields = fields
|
|
197
201
|
|
|
198
202
|
this.localFilterThreshold = localFilterThreshold
|
|
203
|
+
this.routeState = routeState
|
|
199
204
|
this.nullSort = nullSort
|
|
200
205
|
this._cacheTtlMs = cacheTtlMs
|
|
201
206
|
this._asymmetric = asymmetric
|
|
@@ -16,6 +16,7 @@ import type { HookRegistry } from '../hooks/HookRegistry'
|
|
|
16
16
|
|
|
17
17
|
// Circular type import — safe because `import type` is erased at runtime
|
|
18
18
|
import type { EntityManager } from './EntityManager'
|
|
19
|
+
import type { RouteStatePersister } from '../routeState'
|
|
19
20
|
|
|
20
21
|
// ============ INTERNAL TYPES ============
|
|
21
22
|
|
|
@@ -235,6 +236,16 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
235
236
|
badges?: (entity: T) => EntityBadge[]
|
|
236
237
|
fields?: Record<string, FieldConfig>
|
|
237
238
|
localFilterThreshold?: number | null
|
|
239
|
+
/**
|
|
240
|
+
* Where lists of this entity remember their state (#2146): a slug the
|
|
241
|
+
* framework knows, or a persister instance.
|
|
242
|
+
*
|
|
243
|
+
* A property of the ENTITY, so every screen showing it agrees — a list of
|
|
244
|
+
* audit rows nobody links to can say `local_storage` once here rather than
|
|
245
|
+
* on each of the four pages that render it. A list's own `routeState`
|
|
246
|
+
* still wins; the kernel default applies when neither says anything.
|
|
247
|
+
*/
|
|
248
|
+
routeState?: string | RouteStatePersister
|
|
238
249
|
/** Default null placement for local sorts: 'first' | 'last' | 'low' | 'high' (default 'last', #1222). Field-level nullSort wins. */
|
|
239
250
|
nullSort?: NullSortMode
|
|
240
251
|
/** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
|
|
@@ -151,7 +151,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
|
|
154
|
-
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
154
|
+
const { page = 1, page_size = 20, sort_by, sort_order, search, filters = {} } = params
|
|
155
155
|
|
|
156
156
|
// `paramMapping` covers the WHOLE outgoing query, pagination included
|
|
157
157
|
// (#2113). It used to be applied to the filters only, so `status` could
|
|
@@ -160,9 +160,23 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
160
160
|
// storage for that alone. The names qdadm uses internally are its own
|
|
161
161
|
// business; what goes on the wire is the backend's.
|
|
162
162
|
//
|
|
163
|
+
// `search` goes out too (#2147). It is part of `ListParams` and always
|
|
164
|
+
// has been, but this method never read it — a user typed in the search
|
|
165
|
+
// box, the list rebuilt its query, and the term was dropped here without
|
|
166
|
+
// a word. The rows came back unfiltered and nothing anywhere said why,
|
|
167
|
+
// which is the silent no-op ADR 0011 exists to forbid.
|
|
168
|
+
//
|
|
169
|
+
// It travels under whatever name your backend uses, like every other key:
|
|
170
|
+
// `paramMapping: { search: 'q' }`.
|
|
171
|
+
//
|
|
172
|
+
// `searchFields` deliberately stays behind. It says which fields the
|
|
173
|
+
// FRONT should look at when filtering a cached page locally; a backend
|
|
174
|
+
// that searches knows its own columns, and shipping an array parameter
|
|
175
|
+
// nobody asked for would be a new surprise in place of the old one.
|
|
176
|
+
//
|
|
163
177
|
// Filters keep their precedence over the pagination keys, exactly as
|
|
164
178
|
// before: a filter named `page` still wins, for better or worse.
|
|
165
|
-
const outgoing = { page, page_size, sort_by, sort_order, ...filters }
|
|
179
|
+
const outgoing = { page, page_size, sort_by, sort_order, search, ...filters }
|
|
166
180
|
|
|
167
181
|
const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
|
|
168
182
|
params: this._applyParamMapping(outgoing),
|
|
@@ -67,7 +67,7 @@ export class LocalStorage<T extends EntityRecord = EntityRecord> extends IStorag
|
|
|
67
67
|
items = filterItems(items, filters, { stringMatch: 'exact' })
|
|
68
68
|
|
|
69
69
|
// Apply search (shared pipeline, #1192)
|
|
70
|
-
items = searchItems(items, search
|
|
70
|
+
items = searchItems(items, search)
|
|
71
71
|
|
|
72
72
|
const total = items.length
|
|
73
73
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IStorage } from './IStorage'
|
|
2
2
|
import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
|
|
3
3
|
import { StorageError } from './errors'
|
|
4
|
-
import { sortItems, filterItems, paginate, defaultGenerateId } from '../../query/clientFilter'
|
|
4
|
+
import { sortItems, filterItems, paginate, defaultGenerateId, searchItems } from '../../query/clientFilter'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* MemoryStorage options
|
|
@@ -61,10 +61,15 @@ export class MemoryStorage<T extends EntityRecord = EntityRecord> extends IStora
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
async list(params: ListParams = {}): Promise<ListResult<T>> {
|
|
64
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
64
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
65
65
|
|
|
66
66
|
let items = this._getAll()
|
|
67
67
|
|
|
68
|
+
// Apply the search term (#2147). `searchItems` has existed since #1192
|
|
69
|
+
// and LocalStorage and MockApiStorage both called it; this one never did,
|
|
70
|
+
// so a search box over a MemoryStorage entity typed into the void.
|
|
71
|
+
items = searchItems(items, search)
|
|
72
|
+
|
|
68
73
|
// Apply filters (shared pipeline, #1192 — legacy substring semantics)
|
|
69
74
|
items = filterItems(items, filters, { stringMatch: 'includes' })
|
|
70
75
|
|
|
@@ -151,7 +151,7 @@ export class MockApiStorage<T extends EntityRecord = EntityRecord> extends IStor
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// Apply search (shared pipeline, #1192)
|
|
154
|
-
items = searchItems(items, search
|
|
154
|
+
items = searchItems(items, search)
|
|
155
155
|
|
|
156
156
|
const total = items.length
|
|
157
157
|
|