@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
|
@@ -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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IStorage } from './IStorage'
|
|
2
2
|
import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
|
|
3
|
-
import { sortItems, filterItems, paginate } from '../../query/clientFilter'
|
|
3
|
+
import { sortItems, filterItems, paginate, searchItems } from '../../query/clientFilter'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SDK method result with data property
|
|
@@ -275,9 +275,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
async list(params: ListParams = {}): Promise<ListResult<T>> {
|
|
278
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
278
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// `search` goes to the SDK with everything else (#2147). It was dropped
|
|
281
|
+
// here, so a search box over an SDK-backed entity typed into the void:
|
|
282
|
+
// the call went out without the term and the rows came back unfiltered,
|
|
283
|
+
// with nothing anywhere saying why.
|
|
284
|
+
const queryParams = { query: { page, page_size, sort_by, sort_order, search, ...filters } }
|
|
281
285
|
const data = (await this._execute('list', queryParams)) as ListResult<T>
|
|
282
286
|
|
|
283
287
|
let { items, total } = data
|
|
@@ -292,8 +296,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
292
296
|
|
|
293
297
|
// Shared pipeline (#1192) — original order preserved: sort, then
|
|
294
298
|
// filter (substring semantics), then paginate.
|
|
299
|
+
//
|
|
300
|
+
// The search is applied here TOO, not only sent (#2147). An SDK that
|
|
301
|
+
// ignores the term would otherwise hand back the whole collection and
|
|
302
|
+
// this branch would paginate it as if it had been searched.
|
|
295
303
|
items = sortItems([...items], sort_by, sort_order)
|
|
296
304
|
items = filterItems(items, filters, { stringMatch: 'includes' })
|
|
305
|
+
items = searchItems(items, search)
|
|
297
306
|
total = items.length
|
|
298
307
|
items = paginate(items, page, page_size)
|
|
299
308
|
}
|
package/src/index.ts
CHANGED
|
@@ -237,10 +237,13 @@ export * from './composables/useJsonSyntax'
|
|
|
237
237
|
export {
|
|
238
238
|
useListPage,
|
|
239
239
|
PAGE_SIZE_OPTIONS,
|
|
240
|
+
FILTER_TYPES,
|
|
240
241
|
type UseListPageOptions,
|
|
241
242
|
type UseListPageReturn,
|
|
242
243
|
type ColumnConfig,
|
|
243
244
|
type FilterConfig,
|
|
245
|
+
type FilterOptions,
|
|
246
|
+
type FilterType,
|
|
244
247
|
type ActionConfig as ListActionConfig,
|
|
245
248
|
type ResolvedAction,
|
|
246
249
|
type HeaderActionConfig,
|
|
@@ -322,6 +325,22 @@ export {
|
|
|
322
325
|
type LayoutMeta,
|
|
323
326
|
} from './composables/useLayoutResolver'
|
|
324
327
|
export { useSSEBridge, type UseSSEBridgeReturn } from './composables/useSSEBridge'
|
|
328
|
+
|
|
329
|
+
// Route-state persistence (#2146) — where a route remembers what it shows.
|
|
330
|
+
export {
|
|
331
|
+
UrlPersister,
|
|
332
|
+
isRouteStatePersister,
|
|
333
|
+
resolveRouteStatePersister,
|
|
334
|
+
createRouteStatePersisterFactory,
|
|
335
|
+
type RouteStatePersister,
|
|
336
|
+
type RouteStateScope,
|
|
337
|
+
type RouteStatePersisterSpec,
|
|
338
|
+
type RouteStatePersisterFactory,
|
|
339
|
+
type UrlPersisterOptions,
|
|
340
|
+
dottedKeyLocator,
|
|
341
|
+
namespacedKeyLocator,
|
|
342
|
+
type RouteStateKeyLocator,
|
|
343
|
+
} from './routeState'
|
|
325
344
|
export {
|
|
326
345
|
useDeferred,
|
|
327
346
|
useDeferredValue,
|
|
@@ -361,8 +361,8 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
361
361
|
'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
|
|
362
362
|
'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
|
|
363
363
|
'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
|
|
364
|
-
'root', 'routeParamResolver', 'routePrefix', '
|
|
365
|
-
'sse', 'storageResolver', 'toast', 'warmup',
|
|
364
|
+
'root', 'routeParamResolver', 'routePrefix', 'routeState', 'sectionOrder',
|
|
365
|
+
'security', 'sse', 'storageResolver', 'toast', 'warmup',
|
|
366
366
|
])
|
|
367
367
|
|
|
368
368
|
proto._validateKernelOptions = function (this: Self): void {
|
|
@@ -12,6 +12,7 @@ import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
|
12
12
|
import type { RoleProvider } from '../security/RolesProvider'
|
|
13
13
|
import type { I18nOptions } from '../i18n/types'
|
|
14
14
|
import type { ApiClientSource } from '../api/apiClient'
|
|
15
|
+
import type { RouteStatePersister } from '../routeState'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Auth adapter interface (app-level authentication)
|
|
@@ -218,6 +219,21 @@ export interface KernelOptions {
|
|
|
218
219
|
defaultEntityCacheTtlMs?: number
|
|
219
220
|
eventRouter?: RoutesConfig
|
|
220
221
|
sse?: SSEConfig
|
|
222
|
+
/**
|
|
223
|
+
* Where screens remember their route state by default (#2146): a slug the
|
|
224
|
+
* framework knows (`'url'`, `'local_storage'`, `'cookie'`, `'memory'`,
|
|
225
|
+
* `'none'`), or a persister instance.
|
|
226
|
+
*
|
|
227
|
+
* The app-wide floor. An entity overrides it for every screen showing that
|
|
228
|
+
* entity; a single list overrides it for itself. Unset means `'url'`, which
|
|
229
|
+
* is the only medium where what you are looking at is also what you can
|
|
230
|
+
* send someone.
|
|
231
|
+
*
|
|
232
|
+
* An unknown slug throws at list construction rather than falling back —
|
|
233
|
+
* a persistence choice that quietly does something else is the failure
|
|
234
|
+
* ADR 0011 forbids.
|
|
235
|
+
*/
|
|
236
|
+
routeState?: string | RouteStatePersister
|
|
221
237
|
debugBar?: DebugBarConfig
|
|
222
238
|
notifications?: NotificationsConfig
|
|
223
239
|
toast?: Record<string, unknown>
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -323,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
326
|
+
// The app-wide route-state default (#2146). Provided even when unset, so
|
|
327
|
+
// a list can tell "nobody chose" from "somebody chose and it was null".
|
|
328
|
+
app.provide('qdadmRouteState', this.options.routeState ?? null)
|
|
326
329
|
app.provide('qdadmDeferred', this.deferred)
|
|
327
330
|
app.provide('qdadmLayoutComponents', this.layoutComponents)
|
|
328
331
|
// Surface PrimeVue presence so host-rendered <QdadmRoot /> can
|
package/src/plugin.ts
CHANGED
|
@@ -107,7 +107,7 @@ export interface QdadmPlugin {
|
|
|
107
107
|
export function createQdadm(options: QdadmOptions): QdadmPlugin {
|
|
108
108
|
return {
|
|
109
109
|
install(app: App) {
|
|
110
|
-
//
|
|
110
|
+
// The contracts this plugin cannot run without.
|
|
111
111
|
if (!options.entityFactory && !options.orchestrator && !options.managers) {
|
|
112
112
|
throw new Error('[qdadm] entityFactory, orchestrator, or managers required')
|
|
113
113
|
}
|
|
@@ -103,9 +103,20 @@ export function filterItems<T>(
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Substring match on every string field (the adapters' full-text search). */
|
|
106
|
-
export function searchItems<T>(items: T[], search?:
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
export function searchItems<T>(items: T[], search?: unknown): T[] {
|
|
107
|
+
// A NUMBER is a search term too (#2147). This used to require a string and
|
|
108
|
+
// return the list untouched for anything else — so searching an order id
|
|
109
|
+
// that had been through a numeric round trip filtered nothing, silently,
|
|
110
|
+
// which is the failure ADR 0011 forbids. Objects and booleans are still
|
|
111
|
+
// ignored: there is no sensible text for them.
|
|
112
|
+
const term =
|
|
113
|
+
typeof search === 'string'
|
|
114
|
+
? search
|
|
115
|
+
: typeof search === 'number' || typeof search === 'bigint'
|
|
116
|
+
? String(search)
|
|
117
|
+
: ''
|
|
118
|
+
if (!term.trim()) return items
|
|
119
|
+
const query = term.toLowerCase().trim()
|
|
109
120
|
return items.filter((item) => {
|
|
110
121
|
for (const value of Object.values(item as Record<string, unknown>)) {
|
|
111
122
|
if (typeof value === 'string' && value.toLowerCase().includes(query)) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One state, several media (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The seam's signature was chosen so composition would be expressible without
|
|
5
|
+
* changing it, and this is the case that needed it: qdadm's own default.
|
|
6
|
+
* Filters, search and the page belong in a link; rows-per-page does not — it
|
|
7
|
+
* is a comfort setting somebody chose once, and putting it in the URL would
|
|
8
|
+
* mean a link imposing the sender's row count on the reader.
|
|
9
|
+
*
|
|
10
|
+
* So the default routes by key: everything to the URL, `pageSize` to the
|
|
11
|
+
* cookie it has always lived in.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* new CompositePersister({
|
|
15
|
+
* fallback: new UrlPersister({ router, route }),
|
|
16
|
+
* keys: { pageSize: { persister: new CookiePersister(), scope: 'app' } },
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* A ROUTED KEY MAY PIN ITS OWN SCOPE. `pageSize` is remembered once for the
|
|
21
|
+
* whole app rather than per list — that is what the year-long
|
|
22
|
+
* `qdadm_pageSize` cookie already did, and expressing it here makes it a
|
|
23
|
+
* visible decision instead of an accident of where the code happened to live.
|
|
24
|
+
*/
|
|
25
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
26
|
+
|
|
27
|
+
export interface RoutedKey {
|
|
28
|
+
persister: RouteStatePersister
|
|
29
|
+
/**
|
|
30
|
+
* Remember this key under a fixed scope instead of the caller's.
|
|
31
|
+
*
|
|
32
|
+
* For state that is one setting across the whole app rather than one per
|
|
33
|
+
* screen. Leave it out and the key follows the scope like everything else.
|
|
34
|
+
*/
|
|
35
|
+
scope?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CompositePersisterOptions {
|
|
39
|
+
/** Where anything unrouted goes. */
|
|
40
|
+
fallback: RouteStatePersister
|
|
41
|
+
/** Keys that live somewhere else. */
|
|
42
|
+
keys?: Record<string, RoutedKey | RouteStatePersister>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CompositePersister implements RouteStatePersister {
|
|
46
|
+
readonly name: string
|
|
47
|
+
|
|
48
|
+
private readonly _fallback: RouteStatePersister
|
|
49
|
+
private readonly _keys: Map<string, RoutedKey>
|
|
50
|
+
|
|
51
|
+
constructor(options: CompositePersisterOptions) {
|
|
52
|
+
this._fallback = options.fallback
|
|
53
|
+
this._keys = new Map(
|
|
54
|
+
Object.entries(options.keys ?? {}).map(([key, routed]) => [
|
|
55
|
+
key,
|
|
56
|
+
'persister' in routed ? routed : { persister: routed },
|
|
57
|
+
])
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const media = new Set([
|
|
61
|
+
this._fallback.name,
|
|
62
|
+
...[...this._keys.values()].map((routed) => routed.persister.name),
|
|
63
|
+
])
|
|
64
|
+
this.name = `composite(${[...media].join('+')})`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The fallback's state, with each routed key overlaid from its own medium.
|
|
69
|
+
*
|
|
70
|
+
* Order matters: a routed key wins even if the fallback happens to hold one
|
|
71
|
+
* under the same name — a stale `?pageSize=50` left in somebody's URL must
|
|
72
|
+
* not beat the cookie that owns the setting.
|
|
73
|
+
*/
|
|
74
|
+
read(scope: string): Record<string, unknown> | null {
|
|
75
|
+
const base = this._fallback.read(scope)
|
|
76
|
+
const state: Record<string, unknown> = base ? { ...base } : {}
|
|
77
|
+
let found = base !== null
|
|
78
|
+
|
|
79
|
+
// Read each medium once, however many keys it owns.
|
|
80
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
81
|
+
const stored = medium.persister.read(medium.scope ?? scope)
|
|
82
|
+
if (!stored) continue
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
if (stored[key] === undefined) continue
|
|
85
|
+
found = true
|
|
86
|
+
state[key] = stored[key]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return found ? state : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split the state by owner and write each medium once.
|
|
95
|
+
*
|
|
96
|
+
* A routed key ABSENT from `state` is still passed on as absent, so the
|
|
97
|
+
* empty-means-removed rule reaches the medium that owns it — otherwise
|
|
98
|
+
* clearing a filter would leave it behind in whichever store it lived in.
|
|
99
|
+
*/
|
|
100
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
101
|
+
const forFallback: Record<string, unknown> = {}
|
|
102
|
+
for (const [key, value] of Object.entries(state)) {
|
|
103
|
+
if (this._keys.has(key)) continue
|
|
104
|
+
forFallback[key] = value
|
|
105
|
+
}
|
|
106
|
+
this._fallback.write(scope, forFallback)
|
|
107
|
+
|
|
108
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
109
|
+
const slice: Record<string, unknown> = {}
|
|
110
|
+
for (const key of keys) slice[key] = state[key]
|
|
111
|
+
medium.persister.write(medium.scope ?? scope, slice)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clear the fallback, and any routed key that follows the caller's scope.
|
|
117
|
+
*
|
|
118
|
+
* A key PINNED to its own scope is deliberately left alone: `pageSize` is
|
|
119
|
+
* an app-wide setting somebody chose, and "clear this list's filters" is
|
|
120
|
+
* not a request to reset it. Clearing it here would make one list's clear
|
|
121
|
+
* button change every other list's row count.
|
|
122
|
+
*/
|
|
123
|
+
clear(scope: string): void {
|
|
124
|
+
this._fallback.clear(scope)
|
|
125
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
126
|
+
if (medium.scope !== undefined) continue
|
|
127
|
+
const slice: Record<string, unknown> = {}
|
|
128
|
+
for (const key of keys) slice[key] = null
|
|
129
|
+
medium.persister.write(scope, slice)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Group routed keys by the (persister, scope) pair that owns them. */
|
|
134
|
+
private _byMedium(): Map<RoutedKey, string[]> {
|
|
135
|
+
const grouped = new Map<string, { medium: RoutedKey; keys: string[] }>()
|
|
136
|
+
for (const [key, routed] of this._keys) {
|
|
137
|
+
const id = `${routed.persister.name} ${routed.scope ?? ''}`
|
|
138
|
+
const existing = grouped.get(id)
|
|
139
|
+
if (existing) existing.keys.push(key)
|
|
140
|
+
else grouped.set(id, { medium: routed, keys: [key] })
|
|
141
|
+
}
|
|
142
|
+
return new Map([...grouped.values()].map(({ medium, keys }) => [medium, keys]))
|
|
143
|
+
}
|
|
144
|
+
}
|