@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "test": "vitest run",
20
20
  "test:watch": "vitest",
21
21
  "gen:i18n-defaults": "node scripts/gen-i18n-defaults.mjs",
22
- "type-check": "vue-tsc --noEmit",
22
+ "type-check": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.typetest.json",
23
23
  "lint": "eslint src/",
24
24
  "lint:fix": "eslint src/ --fix",
25
25
  "format": "prettier --write src/",
@@ -54,7 +54,7 @@
54
54
  "dist/node"
55
55
  ],
56
56
  "dependencies": {
57
- "@quazardous/qdcore": "^1.1.2",
57
+ "@quazardous/qdcore": "^1.2.0",
58
58
  "@quazardous/qddebug": "^1.2.0",
59
59
  "@quazardous/quarkernel": "^2.1.0",
60
60
  "yaml": "^2.8.4"
@@ -63,6 +63,7 @@
63
63
  "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
64
64
  "primevue": "^4.0.0",
65
65
  "sass-embedded": "^1.0.0",
66
+ "vanilla-jsoneditor": "^3.13.0",
66
67
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
67
68
  "vue": "^3.3.0",
68
69
  "vue-router": "^4.0.0 || ^5.0.0"
@@ -78,12 +79,9 @@
78
79
  "optional": true
79
80
  },
80
81
  "sass-embedded": {
81
- "optional": "true"
82
+ "optional": true
82
83
  }
83
84
  },
84
- "optionalDependencies": {
85
- "vanilla-jsoneditor": "^0.23.0"
86
- },
87
85
  "keywords": [
88
86
  "vue",
89
87
  "vue3",
@@ -15,7 +15,17 @@
15
15
  */
16
16
 
17
17
  import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
18
- import { JSONEditor, type Content, type ContentErrors, type OnChangeStatus, type Mode } from 'vanilla-jsoneditor'
18
+ // vanilla-jsoneditor 3.x (#1901): the `new JSONEditor(...)` constructor is
19
+ // deprecated — `JSONEditor` is now the Svelte component itself, and the
20
+ // instance type is exported as `JsonEditor`.
21
+ import {
22
+ createJSONEditor,
23
+ type JsonEditor,
24
+ type Content,
25
+ type ContentErrors,
26
+ type OnChangeStatus,
27
+ type Mode,
28
+ } from 'vanilla-jsoneditor'
19
29
  import { buildJsonValidator, type Validator, type JSONSchema, type JsonValidatorOptions } from './jsonValidator'
20
30
 
21
31
  type JsonValue = Record<string, unknown> | unknown[] | string | null
@@ -74,8 +84,8 @@ const emit = defineEmits<{
74
84
  'error': [errors: ContentErrors]
75
85
  }>()
76
86
 
77
- const containerRef = ref<HTMLElement | null>(null)
78
- let editor: JSONEditor | null = null
87
+ const containerRef = ref<HTMLDivElement | null>(null)
88
+ let editor: JsonEditor | null = null
79
89
  // Flag to prevent onChange from firing during programmatic updates (instance-specific)
80
90
  const updatingFromProp = ref<boolean>(false)
81
91
 
@@ -102,7 +112,7 @@ onMounted(() => {
102
112
  json: parseValue(props.modelValue)
103
113
  }
104
114
 
105
- editor = new JSONEditor({
115
+ editor = createJSONEditor({
106
116
  target: containerRef.value,
107
117
  props: {
108
118
  content,
@@ -104,6 +104,12 @@ const props = defineProps({
104
104
  rows: { type: Number, default: 10 },
105
105
  rowsPerPageOptions: { type: Array as PropType<number[]>, default: () => [10, 50, 100] },
106
106
  totalRecords: { type: Number, default: 0 },
107
+ /**
108
+ * Row offset of the page being shown — what positions the paginator (#2145).
109
+ * Required in lazy mode: the table is handed one page of rows and cannot
110
+ * infer which page they are.
111
+ */
112
+ first: { type: Number, default: 0 },
107
113
  lazy: { type: Boolean, default: false },
108
114
 
109
115
  // Sorting
@@ -438,6 +444,7 @@ function getFilterClass(filter: FilterConfig): Record<string, boolean> {
438
444
  :rows="rows"
439
445
  :rowsPerPageOptions="rowsPerPageOptions"
440
446
  :totalRecords="totalRecords"
447
+ :first="first"
441
448
  :lazy="lazy"
442
449
  :sortField="sortField ?? undefined"
443
450
  :sortOrder="sortOrder"
@@ -30,10 +30,13 @@ export * from './useJsonSyntax'
30
30
  export {
31
31
  useListPage,
32
32
  PAGE_SIZE_OPTIONS,
33
+ FILTER_TYPES,
33
34
  type UseListPageOptions,
34
35
  type UseListPageReturn,
35
36
  type ColumnConfig,
36
37
  type FilterConfig,
38
+ type FilterOptions,
39
+ type FilterType,
37
40
  type ActionConfig as ListActionConfig,
38
41
  type ResolvedAction,
39
42
  type HeaderActionConfig,
@@ -34,6 +34,10 @@ import { useRoute } from 'vue-router'
34
34
  export const LAYOUT_TYPES = {
35
35
  LIST: 'list',
36
36
  FORM: 'form',
37
+ // `show` was emitted by crud({ show }) long before it existed here (#1922).
38
+ // The resolver returned it, found no component under that key, and fell
39
+ // back to `base` — gracefully, which is exactly what made it invisible.
40
+ SHOW: 'show',
37
41
  DASHBOARD: 'dashboard',
38
42
  BASE: 'base',
39
43
  } as const
@@ -46,6 +50,7 @@ export type LayoutType = (typeof LAYOUT_TYPES)[keyof typeof LAYOUT_TYPES]
46
50
  export interface LayoutComponentsMap {
47
51
  list?: Component | null
48
52
  form?: Component | null
53
+ show?: Component | null
49
54
  dashboard?: Component | null
50
55
  base?: Component | null
51
56
  [key: string]: Component | null | undefined
@@ -63,6 +68,7 @@ interface PageNamePattern {
63
68
  const PAGE_NAME_PATTERNS: PageNamePattern[] = [
64
69
  { pattern: /List(Page)?$/i, layout: LAYOUT_TYPES.LIST },
65
70
  { pattern: /(Edit|Create|Form)(Page)?$/i, layout: LAYOUT_TYPES.FORM },
71
+ { pattern: /Show(Page)?$/i, layout: LAYOUT_TYPES.SHOW },
66
72
  { pattern: /Dashboard(Page)?$/i, layout: LAYOUT_TYPES.DASHBOARD },
67
73
  ]
68
74
 
@@ -70,6 +76,10 @@ const PAGE_NAME_PATTERNS: PageNamePattern[] = [
70
76
  const ROUTE_NAME_PATTERNS: PageNamePattern[] = [
71
77
  { pattern: /-list$/i, layout: LAYOUT_TYPES.LIST },
72
78
  { pattern: /-(edit|create)$/i, layout: LAYOUT_TYPES.FORM },
79
+ // Load-bearing, not decoration: an app that declares its detail route by
80
+ // hand rather than through crud({ show }) has no meta.layout to go on, and
81
+ // this is the only path that can reach `show` for it (#1922).
82
+ { pattern: /-show$/i, layout: LAYOUT_TYPES.SHOW },
73
83
  { pattern: /-dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
74
84
  // Common patterns without suffix
75
85
  { pattern: /^dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
@@ -7,9 +7,10 @@
7
7
  * useListPage composes it back in; behavior is unchanged.
8
8
  */
9
9
  import { ref, computed, type Ref, type ComputedRef } from 'vue'
10
- import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
11
10
  import { FilterQuery, type QueryOrchestratorLike } from '../query/FilterQuery'
12
- import type { FilterConfig, SearchConfig } from './useListPage.types'
11
+ import { FILTER_TYPES } from './useListPage.types'
12
+ import type { FilterConfig, FilterOptions, SearchConfig } from './useListPage.types'
13
+ import type { RouteStatePersister } from '../routeState'
13
14
  import {
14
15
  SMART_FILTER_THRESHOLD,
15
16
  clearSessionFilters,
@@ -29,14 +30,38 @@ export interface UseListFiltersDeps {
29
30
  items: Ref<unknown[]>
30
31
  /** Current page ref — filter changes reset it to 1. */
31
32
  page: Ref<number>
33
+ /** Rows per page — persisted alongside the rest (#2146). */
34
+ pageSize: Ref<number>
35
+ /** Active sort field, persisted alongside the rest (#2146). */
36
+ sortField: Ref<string | null>
37
+ /** Active sort direction. */
38
+ sortOrder: Ref<number>
39
+ /**
40
+ * Whether the sort is remembered at all.
41
+ *
42
+ * Its own flag rather than folded into `routeStateWrites`, because it has
43
+ * always been separately switchable and a seam refactor must not quietly
44
+ * take an option away.
45
+ */
46
+ persistSort: boolean
32
47
  /** Search query ref (shared with the search subsystem). */
33
48
  searchQuery: Ref<string>
34
- route: RouteLocationNormalizedLoaded
35
- router: Router
36
49
  /** Session-restored filter values (already stripped of _search). */
37
50
  savedFilters: Record<string, unknown> | null
38
51
  persistFilters: boolean
39
- syncUrlParams: boolean
52
+ /** Where this list's state is remembered (#2146). */
53
+ persister: RouteStatePersister | null
54
+ /** This list's namespace, so two lists on one route stop colliding. */
55
+ routeStateScope: string
56
+ /**
57
+ * Whether this list may WRITE its state back.
58
+ *
59
+ * Separate from having a persister because reading and writing were always
60
+ * separate here: `syncUrlParams: false` stopped the query string being
61
+ * written and never stopped it being read, so a hand-typed deep link still
62
+ * restored. Collapsing the two would have changed that quietly.
63
+ */
64
+ routeStateWrites: boolean
40
65
  autoLoadFilters: boolean
41
66
  filterSessionKey: string
42
67
  /** Entity filters registry (injected by the consuming app). */
@@ -51,7 +76,7 @@ export interface UseListFiltersReturn {
51
76
  filterValues: Ref<Record<string, unknown>>
52
77
  filters: ComputedRef<FilterConfig[]>
53
78
  hasActiveFilters: ComputedRef<boolean>
54
- addFilter: (name: string, filterConfig: Omit<FilterConfig, 'name'>) => void
79
+ addFilter: (name: string, filterConfig: FilterOptions) => void
55
80
  removeFilter: (name: string) => void
56
81
  setFilterValue: (name: string, value: unknown) => void
57
82
  updateFilters: (newValues: Record<string, unknown>) => void
@@ -74,11 +99,15 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
74
99
  items,
75
100
  page,
76
101
  searchQuery,
77
- route,
78
- router,
79
102
  savedFilters,
80
103
  persistFilters,
81
- syncUrlParams,
104
+ persister,
105
+ routeStateScope,
106
+ routeStateWrites,
107
+ pageSize,
108
+ sortField,
109
+ sortOrder,
110
+ persistSort,
82
111
  autoLoadFilters,
83
112
  filterSessionKey,
84
113
  entityFilters,
@@ -89,16 +118,21 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
89
118
  const filtersMap = ref<Map<string, FilterConfig>>(new Map())
90
119
  const filterValues = ref<Record<string, unknown>>(savedFilters || {})
91
120
 
92
- /** Names the URL sync already owns — a filter cannot have them (#2113). */
121
+ /** Names the route state already owns — a filter cannot have them (#2113). */
93
122
  const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
94
123
  const warnedReserved = new Set<string>()
95
124
 
96
125
  /**
97
- * A filter named `page` or `search` silently overwrites the query key of
126
+ * A filter named `page` or `search` silently overwrites the stored key of
98
127
  * the same name, and is overwritten back on restore. `search` has behaved
99
128
  * this way since the URL sync existed and nobody ever reported it, which
100
- * says how quiet the failure is; `page` joins the reserved set now that the
101
- * page number lives in the URL too.
129
+ * says how quiet the failure is; `page` joined it once the page number
130
+ * started being remembered too.
131
+ *
132
+ * SCOPING DID NOT FIX THIS. Prefixing keys by entity separates this list
133
+ * from other lists; it does not separate the list from qdadm's own two
134
+ * keys, which live in the same scope it does — `offers.page` is written by
135
+ * the pager whatever a filter called `page` wants.
102
136
  *
103
137
  * We name what happens INSTEAD of what was asked, rather than dropping the
104
138
  * filter — renaming someone's filter behind their back would be a worse
@@ -106,20 +140,53 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
106
140
  */
107
141
  function warnIfReservedName(name: string): void {
108
142
  if (!RESERVED_QUERY_KEYS.has(name)) return
109
- if (!syncUrlParams) return
143
+ // Gated on writing, not on the URL specifically: the collision is with
144
+ // whatever medium this list persists to.
145
+ if (!routeStateWrites) return
110
146
  if (warnedReserved.has(name)) return
111
147
  warnedReserved.add(name)
112
148
  console.warn(
113
- `[qdadm] Filter "${name}" on "${entityName}" collides with the URL ` +
114
- `parameter of the same name. With syncUrlParams on, the list's own ` +
149
+ `[qdadm] Filter "${name}" on "${entityName}" collides with the key of ` +
150
+ `the same name in this list's persisted route state. The list's own ` +
115
151
  `${name === 'page' ? 'page number' : 'search query'} wins and this ` +
116
- `filter will not survive a reload. Rename the filter, or set ` +
117
- `syncUrlParams: false on this list.`
152
+ `filter will not survive a reload. Rename the filter, or turn the ` +
153
+ `persistence off on this list with syncUrlParams: false.`
154
+ )
155
+ }
156
+
157
+ const warnedTypes = new Set<string>()
158
+
159
+ /**
160
+ * A filter type qdadm cannot render (#2147).
161
+ *
162
+ * `ListPage` is binary — an autocomplete, or a `Select` for everything
163
+ * else, unknown types included. So an invented type does not fail; it
164
+ * renders as a dropdown with no options, reading "No available options",
165
+ * and the user concludes there is nothing to choose. Reported by a consumer
166
+ * whose two `type: 'text'` filters had never filtered anything.
167
+ *
168
+ * Warned HERE because this is the last place the filter's NAME is known.
169
+ * Three screens later there is only an empty control and no way back to
170
+ * the declaration that caused it.
171
+ */
172
+ function warnIfUnknownType(name: string, type: unknown): void {
173
+ if (type === undefined || type === null) return
174
+ if (FILTER_TYPES.includes(type as (typeof FILTER_TYPES)[number])) return
175
+ const key = `${name}:${String(type)}`
176
+ if (warnedTypes.has(key)) return
177
+ warnedTypes.add(key)
178
+ console.warn(
179
+ `[qdadm] Filter "${name}" on "${entityName}" has type "${String(type)}", ` +
180
+ `which qdadm cannot render. It will fall back to a dropdown, and ` +
181
+ `unless you also give it options that dropdown will be empty — ` +
182
+ `"No available options" — rather than the control you asked for. ` +
183
+ `Known types: ${FILTER_TYPES.join(', ')}.`
118
184
  )
119
185
  }
120
186
 
121
- function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
187
+ function addFilter(name: string, filterConfig: FilterOptions): void {
122
188
  warnIfReservedName(name)
189
+ warnIfUnknownType(name, filterConfig?.type)
123
190
  filtersMap.value.set(name, {
124
191
  name,
125
192
  type: 'select',
@@ -155,34 +222,37 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
155
222
  * for a detail view, so that coming back restores it.
156
223
  */
157
224
  function writeStateToUrl(): void {
158
- if (!syncUrlParams) return
159
-
160
- const query = { ...route.query } as Record<string, string>
161
-
162
- for (const [name, value] of Object.entries(filterValues.value)) {
163
- if (value !== null && value !== undefined && value !== '') {
164
- query[name] = String(value)
165
- } else {
166
- delete query[name]
167
- }
168
- }
169
-
170
- if (searchQuery.value) {
171
- query.search = searchQuery.value
172
- } else {
173
- delete query.search
174
- }
175
-
176
- // Page 1 is the default and stays implicit. Reserved keys are written
177
- // last so the list's own state wins a collision — the warning in
178
- // addFilter() names that consequence at declaration time.
179
- if (page.value > 1) {
180
- query.page = String(page.value)
181
- } else {
182
- delete query.page
183
- }
184
-
185
- router.replace({ query })
225
+ if (!persister || !routeStateWrites) return
226
+
227
+ // Page 1 is the default and stays implicit, exactly like an unset filter:
228
+ // the persister drops null/undefined/'' rather than storing them empty,
229
+ // so a pristine list leaves a clean link.
230
+ //
231
+ // `search` and `page` still share the namespace with the filter names —
232
+ // the scope separates this list from OTHER lists, not from qdadm's own
233
+ // keys — which is why addFilter() still warns about a filter called
234
+ // `page` or `search`.
235
+ persister.write(routeStateScope, {
236
+ ...filterValues.value,
237
+ search: searchQuery.value || null,
238
+ page: page.value > 1 ? page.value : null,
239
+ // Always written, never conditional on a default: rows-per-page is a
240
+ // choice somebody made, and the default is not "unset" — it is what
241
+ // they get back if we forget. The default composition routes it to a
242
+ // cookie, so it does not land in a shareable link.
243
+ pageSize: pageSize.value,
244
+ // Sort goes to the session under the default composition, so it does
245
+ // not reach a link either. `persistSort: false` writes null, which the
246
+ // persister removes — the option has always been able to turn this off
247
+ // and still can.
248
+ //
249
+ // The DIRECTION is stored only when there is a field to apply it to.
250
+ // An order on its own is not partial state, it is meaningless state,
251
+ // and storing it would leave `sortOrder: -1` sitting in every unsorted
252
+ // list's namespace.
253
+ sort: persistSort ? sortField.value : null,
254
+ sortOrder: persistSort && sortField.value ? sortOrder.value : null,
255
+ })
186
256
  }
187
257
 
188
258
  function onFiltersChanged(): void {
@@ -220,15 +290,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
220
290
  clearSessionFilters(filterSessionKey)
221
291
  }
222
292
  page.value = 1
223
- if (syncUrlParams) {
224
- const query = { ...route.query } as Record<string, string>
225
- for (const key of filtersMap.value.keys()) {
226
- delete query[key]
227
- }
228
- delete query.search
229
- delete query.page
230
- router.replace({ query })
231
- }
293
+ if (routeStateWrites) persister?.clear(routeStateScope)
232
294
  loadItems()
233
295
  }
234
296
 
@@ -472,23 +534,31 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
472
534
  }
473
535
 
474
536
  function restoreFilters(): void {
537
+ const stored = persister?.read(routeStateScope)
538
+ if (!stored) return
539
+
540
+ // Only keys this list declares as filters: the scope keeps other lists
541
+ // out, but a persister may still hand back something the app put there.
475
542
  for (const key of filtersMap.value.keys()) {
476
- if (route.query[key] !== undefined) {
477
- let value: unknown = route.query[key]
478
- if (value === 'true') value = true
479
- else if (value === 'false') value = false
480
- else if (value === 'null') value = null
481
- else if (!isNaN(Number(value)) && value !== '') value = Number(value)
482
- filterValues.value[key] = value
483
- }
543
+ if (stored[key] !== undefined) filterValues.value[key] = stored[key]
484
544
  }
485
- if (route.query.search) {
486
- searchQuery.value = route.query.search as string
545
+
546
+ // NOT `typeof === 'string'` (#2147). A term that survives a numeric round
547
+ // trip — an order id, an invoice, a reference — comes back from the URL
548
+ // as a number, and a string-only guard dropped it: the box came back
549
+ // empty and the list unfiltered, so a shared link showed the recipient
550
+ // something other than what the sender searched for.
551
+ //
552
+ // Safe to stringify because the persister no longer coerces lossily: a
553
+ // value that could not be written back as the same text was left as text.
554
+ if (stored.search !== null && stored.search !== undefined && stored.search !== '') {
555
+ searchQuery.value = String(stored.search)
487
556
  }
557
+
488
558
  // The page must be restored HERE, before the first loadItems(): restoring
489
559
  // it later would mean a first request for page 1 and a second for the
490
560
  // real one (#2113).
491
- const restoredPage = Number(route.query.page)
561
+ const restoredPage = Number(stored.page)
492
562
  if (Number.isInteger(restoredPage) && restoredPage > 0) {
493
563
  page.value = restoredPage
494
564
  }