@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.
@@ -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
- export interface FilterConfig {
80
- name: string
81
- type?: 'select' | 'multiselect' | 'date' | 'checkbox' | 'dropdown' | 'autocomplete'
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
- /** Sync filters to URL query params (default: true) */
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: Omit<FilterConfig, 'name'>) => void
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 as string | undefined)
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 as string | undefined)
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
- const queryParams = { query: { page, page_size, sort_by, sort_order, ...filters } }
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', 'sectionOrder', 'security',
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>
@@ -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
- // Validation des contrats requis
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?: string | null): T[] {
107
- if (!search || typeof search !== 'string' || !search.trim()) return items
108
- const query = search.toLowerCase().trim()
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
+ }