@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.
Files changed (38) hide show
  1. package/package.json +5 -7
  2. package/src/components/editors/VanillaJsonEditor.vue +14 -4
  3. package/src/components/lists/ListPage.vue +7 -0
  4. package/src/composables/index.ts +3 -0
  5. package/src/composables/useLayoutResolver.ts +10 -0
  6. package/src/composables/useListPage.filters.ts +137 -67
  7. package/src/composables/useListPage.ts +148 -26
  8. package/src/composables/useListPage.types.ts +74 -5
  9. package/src/composables/useListPage.utils.ts +58 -0
  10. package/src/entity/EntityManager.interface.ts +11 -0
  11. package/src/entity/EntityManager.ts +5 -0
  12. package/src/entity/EntityManager.types.ts +11 -0
  13. package/src/entity/storage/ApiStorage.ts +16 -2
  14. package/src/entity/storage/LocalStorage.ts +1 -1
  15. package/src/entity/storage/MemoryStorage.ts +7 -2
  16. package/src/entity/storage/MockApiStorage.ts +1 -1
  17. package/src/entity/storage/SdkStorage.ts +12 -3
  18. package/src/gen/createManagers.ts +15 -1
  19. package/src/gen/index.ts +7 -1
  20. package/src/index.ts +19 -0
  21. package/src/kernel/Kernel.registries.ts +202 -46
  22. package/src/kernel/Kernel.ts +34 -9
  23. package/src/kernel/Kernel.types.ts +19 -0
  24. package/src/kernel/Kernel.vue.ts +7 -0
  25. package/src/kernel/KernelContext.routing.ts +18 -7
  26. package/src/plugin.ts +1 -1
  27. package/src/query/clientFilter.ts +14 -3
  28. package/src/routeState/CompositePersister.ts +144 -0
  29. package/src/routeState/CookiePersister.ts +109 -0
  30. package/src/routeState/MemoryPersister.ts +60 -0
  31. package/src/routeState/NullPersister.ts +33 -0
  32. package/src/routeState/RouteStatePersister.ts +86 -0
  33. package/src/routeState/UrlPersister.ts +207 -0
  34. package/src/routeState/WebStoragePersister.ts +149 -0
  35. package/src/routeState/defaultPersister.ts +66 -0
  36. package/src/routeState/factory.ts +86 -0
  37. package/src/routeState/index.ts +32 -0
  38. 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
- getSavedPageSize,
97
+ restorePageSize,
90
98
  getSessionFilters,
91
- getSessionSort,
92
- setSessionSort,
93
- persistPageSize,
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
- const pageSize = ref(getSavedPageSize(defaultPageSize))
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 — restored from the per-entity session (#1218), defaultSort as
246
- // fallback; same persistence discipline as filters/pageSize.
247
- const savedSort = persistSort ? getSessionSort(filterSessionKey) : null
248
- const sortField = ref<string | null>(savedSort ? savedSort.field : defaultSort)
249
- const sortOrder = ref(savedSort ? savedSort.order : defaultSortOrder)
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
- persistPageSize(event.rows)
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
- setSessionSort(filterSessionKey, {
823
- field: sortField.value,
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
- router.push(findCreateRoute() as { name: string })
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
- router.push({
864
- name: `${routePrefix}-edit`,
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
- router.push({
871
- name: `${routePrefix}-show`,
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
- 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