qdadm 1.13.1 → 1.19.2

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 (86) hide show
  1. package/package.json +7 -3
  2. package/src/chain/ActiveStack.ts +79 -98
  3. package/src/chain/StackHydrator.ts +3 -2
  4. package/src/chain/index.ts +7 -1
  5. package/src/components/QdadmRoot.vue +52 -0
  6. package/src/components/edit/FormActions.vue +9 -6
  7. package/src/components/edit/LookupPickerDialog.vue +6 -3
  8. package/src/components/index.ts +6 -0
  9. package/src/composables/useEntityItemFormPage.ts +1 -0
  10. package/src/composables/useEntityItemShowPage.ts +1 -0
  11. package/src/composables/useFieldManager.ts +100 -3
  12. package/src/composables/useListPage.ts +50 -59
  13. package/src/composables/useListPage.utils.ts +101 -0
  14. package/src/composables/useNavigation.ts +26 -3
  15. package/src/composables/useOptionsLookup.ts +5 -1
  16. package/src/gen/generateManagers.test.js +27 -0
  17. package/src/gen/generateManagers.ts +12 -0
  18. package/src/hooks/HookRegistry.ts +14 -435
  19. package/src/i18n/I18n.ts +344 -0
  20. package/src/i18n/IncrementalDomainProvider.ts +153 -0
  21. package/src/i18n/InlineTranslationProvider.ts +4 -0
  22. package/src/i18n/LazyTranslationProvider.ts +102 -0
  23. package/src/i18n/MessagesRegistry.ts +4 -0
  24. package/src/i18n/Resolver.ts +4 -0
  25. package/src/i18n/__tests__/I18n.test.ts +169 -0
  26. package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
  27. package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
  28. package/src/i18n/__tests__/Resolver.test.ts +271 -0
  29. package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
  30. package/src/i18n/defaults/core.en.yml +55 -0
  31. package/src/i18n/defaults/core.fr.yml +55 -0
  32. package/src/i18n/index.ts +55 -0
  33. package/src/i18n/loaders/raw-modules.d.ts +15 -0
  34. package/src/i18n/loaders/yaml.ts +35 -0
  35. package/src/i18n/strategies.ts +4 -0
  36. package/src/i18n/types.ts +34 -0
  37. package/src/i18n/useI18n.ts +34 -0
  38. package/src/index.ts +37 -0
  39. package/src/kernel/EventRouter.ts +17 -300
  40. package/src/kernel/Kernel.i18n.ts +29 -0
  41. package/src/kernel/Kernel.modules.ts +6 -0
  42. package/src/kernel/Kernel.registries.ts +10 -2
  43. package/src/kernel/Kernel.routing.ts +43 -1
  44. package/src/kernel/Kernel.ts +43 -0
  45. package/src/kernel/Kernel.types.ts +52 -1
  46. package/src/kernel/Kernel.vue.ts +121 -15
  47. package/src/kernel/KernelContext.entities.ts +80 -0
  48. package/src/kernel/KernelContext.events.ts +57 -0
  49. package/src/kernel/KernelContext.i18n.ts +37 -0
  50. package/src/kernel/KernelContext.permissions.ts +38 -0
  51. package/src/kernel/KernelContext.routing.ts +280 -0
  52. package/src/kernel/KernelContext.ts +125 -834
  53. package/src/kernel/KernelContext.types.ts +173 -0
  54. package/src/kernel/KernelContext.zones.ts +54 -0
  55. package/src/kernel/SSEBridge.ts +7 -362
  56. package/src/kernel/SignalBus.ts +24 -148
  57. package/src/modules/debug/AuthCollector.ts +48 -1
  58. package/src/modules/debug/Collector.ts +16 -302
  59. package/src/modules/debug/DebugBridge.ts +10 -171
  60. package/src/modules/debug/DebugModule.ts +35 -5
  61. package/src/modules/debug/EntitiesCollector.ts +97 -1
  62. package/src/modules/debug/ErrorCollector.ts +2 -77
  63. package/src/modules/debug/I18nCollector.ts +9 -0
  64. package/src/modules/debug/LocalStorageAdapter.ts +3 -147
  65. package/src/modules/debug/RouterCollector.ts +101 -1
  66. package/src/modules/debug/SignalCollector.ts +2 -150
  67. package/src/modules/debug/ToastCollector.ts +2 -91
  68. package/src/modules/debug/ZonesCollector.ts +93 -1
  69. package/src/modules/debug/components/DebugBar.vue +19 -775
  70. package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
  71. package/src/modules/debug/components/index.ts +4 -3
  72. package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
  73. package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
  74. package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
  75. package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
  76. package/src/modules/debug/components/panels/index.ts +10 -4
  77. package/src/modules/debug/index.ts +15 -0
  78. package/src/modules/debug/styles.scss +22 -18
  79. package/src/utils/index.ts +0 -3
  80. package/src/vite/qdadmDebugPlugin.ts +401 -0
  81. package/src/vite-env.d.ts +16 -0
  82. package/src/modules/debug/components/ObjectTree.vue +0 -123
  83. package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
  84. package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
  85. package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
  86. package/src/utils/debugInjector.ts +0 -306
@@ -34,6 +34,7 @@ import { useHooks } from './useHooks.js'
34
34
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
35
35
  import { useActiveStack } from '../chain/useActiveStack.js'
36
36
  import { FilterQuery } from '../query/FilterQuery'
37
+ import { useI18n } from '../i18n/useI18n'
37
38
 
38
39
  // Import types from dedicated types file
39
40
  import type {
@@ -80,63 +81,21 @@ export type {
80
81
  UseListPageReturn,
81
82
  } from './useListPage.types'
82
83
 
83
- // Cookie utilities for pagination persistence
84
- const COOKIE_NAME = 'qdadm_pageSize'
85
- const COOKIE_EXPIRY_DAYS = 365
86
-
87
- function getCookie(name: string): string | null {
88
- const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
89
- return match?.[2] ?? null
90
- }
91
-
92
- function setCookie(name: string, value: string | number, days: number): void {
93
- const expires = new Date(Date.now() + days * 864e5).toUTCString()
94
- document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`
95
- }
96
-
97
- function getSavedPageSize(defaultSize: number): number {
98
- const saved = getCookie(COOKIE_NAME)
99
- if (saved) {
100
- const parsed = parseInt(saved, 10)
101
- if ([10, 50, 100].includes(parsed)) return parsed
102
- }
103
- return defaultSize
104
- }
105
-
106
- // Default label fallback: convert snake_case to Title Case
107
- function snakeToTitle(str: string): string {
108
- if (!str) return 'Unknown'
109
- return String(str)
110
- .split('_')
111
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
112
- .join(' ')
113
- }
114
-
115
- // Standard pagination options
116
- export const PAGE_SIZE_OPTIONS = [10, 50, 100]
117
-
118
- // Session storage utilities for filter persistence
119
- const FILTER_SESSION_PREFIX = 'qdadm_filters_'
120
-
121
- function getSessionFilters(key: string): Record<string, unknown> | null {
122
- try {
123
- const stored = sessionStorage.getItem(FILTER_SESSION_PREFIX + key)
124
- return stored ? JSON.parse(stored) : null
125
- } catch {
126
- return null
127
- }
128
- }
129
-
130
- function setSessionFilters(key: string, filters: Record<string, unknown>): void {
131
- try {
132
- sessionStorage.setItem(FILTER_SESSION_PREFIX + key, JSON.stringify(filters))
133
- } catch {
134
- // Ignore storage errors
135
- }
136
- }
84
+ // Stateless utilities (cookies, session storage, formatters, constants).
85
+ import {
86
+ PAGE_SIZE_OPTIONS,
87
+ SMART_FILTER_THRESHOLD,
88
+ clearSessionFilters,
89
+ getSavedPageSize,
90
+ getSessionFilters,
91
+ persistPageSize,
92
+ setSessionFilters,
93
+ snakeToTitle,
94
+ } from './useListPage.utils'
137
95
 
138
- // Smart filter auto-discovery threshold
139
- const SMART_FILTER_THRESHOLD = 50
96
+ // Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
97
+ // composables/index.ts) keep their import path.
98
+ export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
140
99
 
141
100
 
142
101
  /**
@@ -168,6 +127,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
168
127
  // Mobile detection (viewport width < 768px)
169
128
  const isMobile = ref(window.innerWidth < 768)
170
129
 
130
+ // i18n integration — column headers resolve via entities.{entity}.fields.{field}
131
+ const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
132
+
171
133
  // Get EntityManager via orchestrator
172
134
  const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
173
135
 
@@ -293,15 +255,44 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
293
255
 
294
256
  // ============ COLUMNS ============
295
257
  const columnsMap = ref<Map<string, ColumnConfig>>(new Map())
258
+ // Track inline-supplied column headers so relabel() can fall back to them
259
+ // when the i18n bundle has no translation for the field.
260
+ const columnInlineHeaders = new Map<string, string | undefined>()
261
+
262
+ /**
263
+ * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
264
+ * otherwise the inline `header` option, otherwise a capitalized field name.
265
+ */
266
+ function resolveColumnHeader(field: string, inline?: string): string {
267
+ if (entity && kernelI18n) {
268
+ const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
269
+ if (trace.hit) return trace.result
270
+ }
271
+ return inline || field.charAt(0).toUpperCase() + field.slice(1)
272
+ }
296
273
 
297
274
  function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
275
+ columnInlineHeaders.set(field, columnConfig.header)
298
276
  columnsMap.value.set(field, {
299
277
  field,
300
- header: columnConfig.header || field.charAt(0).toUpperCase() + field.slice(1),
278
+ header: resolveColumnHeader(field, columnConfig.header),
301
279
  ...columnConfig,
280
+ // Re-apply resolved header after spread so it wins over a stale inline.
281
+ ...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
302
282
  })
303
283
  }
304
284
 
285
+ // Re-resolve column headers when locale changes.
286
+ watch(i18nLocale, () => {
287
+ if (!entity || !kernelI18n) return
288
+ for (const [field, current] of columnsMap.value.entries()) {
289
+ const newHeader = resolveColumnHeader(field, columnInlineHeaders.get(field))
290
+ if (current.header !== newHeader) {
291
+ columnsMap.value.set(field, { ...current, header: newHeader })
292
+ }
293
+ }
294
+ })
295
+
305
296
  function removeColumn(field: string): void {
306
297
  columnsMap.value.delete(field)
307
298
  }
@@ -596,7 +587,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
596
587
  filterValues.value = cleared
597
588
  searchQuery.value = ''
598
589
  if (persistFilters) {
599
- sessionStorage.removeItem(FILTER_SESSION_PREFIX + filterSessionKey)
590
+ clearSessionFilters(filterSessionKey)
600
591
  }
601
592
  if (syncUrlParams) {
602
593
  const query = { ...route.query } as Record<string, string>
@@ -1003,7 +994,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1003
994
  function onPage(event: { page: number; rows: number }): void {
1004
995
  page.value = event.page + 1
1005
996
  pageSize.value = event.rows
1006
- setCookie(COOKIE_NAME, event.rows, COOKIE_EXPIRY_DAYS)
997
+ persistPageSize(event.rows)
1007
998
  loadItems()
1008
999
  }
1009
1000
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * useListPage helpers — stateless utilities used by the list page composable.
3
+ *
4
+ * Kept module-local so they're trivially importable from the orchestrator
5
+ * file (`useListPage.ts`) and from any future split modules without dragging
6
+ * the full `useListPage` runtime context along.
7
+ */
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // Pagination cookie persistence
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ const COOKIE_NAME = 'qdadm_pageSize'
14
+ const COOKIE_EXPIRY_DAYS = 365
15
+
16
+ export function getCookie(name: string): string | null {
17
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
18
+ return match?.[2] ?? null
19
+ }
20
+
21
+ export function setCookie(name: string, value: string | number, days: number): void {
22
+ const expires = new Date(Date.now() + days * 864e5).toUTCString()
23
+ document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`
24
+ }
25
+
26
+ /**
27
+ * Read the saved page size from cookie. Returns the default when absent or
28
+ * not in the canonical {@link PAGE_SIZE_OPTIONS} list.
29
+ */
30
+ export function getSavedPageSize(defaultSize: number): number {
31
+ const saved = getCookie(COOKIE_NAME)
32
+ if (saved) {
33
+ const parsed = parseInt(saved, 10)
34
+ if (PAGE_SIZE_OPTIONS.includes(parsed)) return parsed
35
+ }
36
+ return defaultSize
37
+ }
38
+
39
+ export function persistPageSize(size: number): void {
40
+ setCookie(COOKIE_NAME, size, COOKIE_EXPIRY_DAYS)
41
+ }
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Default label formatter (snake_case → Title Case)
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ export function snakeToTitle(str: string): string {
48
+ if (!str) return 'Unknown'
49
+ return String(str)
50
+ .split('_')
51
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
52
+ .join(' ')
53
+ }
54
+
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ // Pagination options
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+
59
+ /** Canonical page size options offered to the user. */
60
+ export const PAGE_SIZE_OPTIONS = [10, 50, 100]
61
+
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ // Filter session storage
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+
66
+ const FILTER_SESSION_PREFIX = 'qdadm_filters_'
67
+
68
+ export function getSessionFilters(key: string): Record<string, unknown> | null {
69
+ try {
70
+ const stored = sessionStorage.getItem(FILTER_SESSION_PREFIX + key)
71
+ return stored ? JSON.parse(stored) : null
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ export function setSessionFilters(key: string, filters: Record<string, unknown>): void {
78
+ try {
79
+ sessionStorage.setItem(FILTER_SESSION_PREFIX + key, JSON.stringify(filters))
80
+ } catch {
81
+ // Ignore storage errors (quota exceeded, disabled by user, …).
82
+ }
83
+ }
84
+
85
+ export function clearSessionFilters(key: string): void {
86
+ try {
87
+ sessionStorage.removeItem(FILTER_SESSION_PREFIX + key)
88
+ } catch {
89
+ // Ignore storage errors.
90
+ }
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Smart filter discovery
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Threshold below which the list switches to client-side filtering when
99
+ * `filterMode: 'auto'` is in effect.
100
+ */
101
+ export const SMART_FILTER_THRESHOLD = 50
@@ -13,6 +13,7 @@ import { computed, inject, ref, onMounted, type ComputedRef, type Ref } from 'vu
13
13
  import { useRoute, useRouter } from 'vue-router'
14
14
  import { getNavSections, alterMenuSections, isMenuAltered } from '../module/moduleRegistry'
15
15
  import { useSemanticBreadcrumb } from './useSemanticBreadcrumb'
16
+ import { useI18n } from '../i18n/useI18n'
16
17
  import type { HookRegistry } from '../hooks/HookRegistry'
17
18
 
18
19
  /**
@@ -81,6 +82,9 @@ export function useNavigation(): UseNavigationReturn {
81
82
  const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator', null)
82
83
  const hooks = inject<HookRegistry | null>('qdadmHooks', null)
83
84
 
85
+ // i18n integration — labels resolve through nav.sections.* / nav.routes.*
86
+ const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
87
+
84
88
  // Semantic breadcrumb for entity-based active detection
85
89
  const { breadcrumb } = useSemanticBreadcrumb()
86
90
 
@@ -112,18 +116,37 @@ export function useNavigation(): UseNavigationReturn {
112
116
  }
113
117
  })
114
118
 
119
+ /**
120
+ * Resolve a label through i18n with the given convention key, falling back
121
+ * to the inline label when the bundle has no matching translation.
122
+ */
123
+ function resolveNavLabel(key: string, fallback: string): string {
124
+ if (!kernelI18n) return fallback
125
+ const trace = kernelI18n.resolve(key)
126
+ return trace.hit ? trace.result : fallback
127
+ }
128
+
115
129
  // Get nav sections from registry, filtering items based on permissions
116
- // Depends on alterVersion to trigger re-computation after alteration
130
+ // Depends on alterVersion + i18nLocale to trigger re-computation after
131
+ // alteration or locale change.
117
132
  const navSections = computed(() => {
118
- // Force dependency on alterVersion for reactivity
133
+ // Force dependencies for reactivity
119
134
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
120
135
  alterVersion.value
136
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
137
+ i18nLocale.value
121
138
 
122
139
  const sections = getNavSections()
123
140
  return sections
124
141
  .map((section) => ({
125
142
  ...section,
126
- items: section.items.filter((item) => canAccessNavItem(item)),
143
+ title: resolveNavLabel(`nav.sections.${section.title}`, section.title),
144
+ items: section.items
145
+ .filter((item) => canAccessNavItem(item))
146
+ .map((item) => ({
147
+ ...item,
148
+ label: resolveNavLabel(`nav.routes.${item.route}`, item.label),
149
+ })),
127
150
  }))
128
151
  .filter((section) => section.items.length > 0) // Hide empty sections
129
152
  })
@@ -170,7 +170,11 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
170
170
  if (config.entity) {
171
171
  try {
172
172
  const orc = useOrchestrator()
173
- getManager = orc.getManager as typeof getManager
173
+ // Generic-vs-non-generic: getManager is parameterised by an
174
+ // EntityRecord type variable that the local alias intentionally
175
+ // erases. Bridge through `unknown` so strict TS doesn't reject
176
+ // the cross-shape assignment.
177
+ getManager = orc.getManager as unknown as typeof getManager
174
178
  } catch {
175
179
  // Orchestrator not available
176
180
  }
@@ -474,6 +474,15 @@ describe('fieldTypeToTsType', () => {
474
474
  expect(fieldTypeToTsType('number', false)).toBe('number | null')
475
475
  expect(fieldTypeToTsType('boolean', false)).toBe('boolean | null')
476
476
  })
477
+
478
+ it('falls back to unknown for unrecognized types instead of emitting literal undefined', () => {
479
+ // Repro for v1.19.1 bug: connectors could yield types outside UnifiedFieldType
480
+ // (e.g. OpenAPI oneOf/discriminator). Without a default branch the IIFE
481
+ // returned `undefined` and template-literal interpolation wrote the string
482
+ // "undefined" into the generated .ts file.
483
+ expect(fieldTypeToTsType('mystery-type', true)).toBe('unknown')
484
+ expect(fieldTypeToTsType('mystery-type', false)).toBe('unknown | null')
485
+ })
477
486
  })
478
487
 
479
488
  describe('generateEntityInterface', () => {
@@ -498,4 +507,22 @@ describe('generateEntityInterface', () => {
498
507
 
499
508
  expect(result).not.toContain('password')
500
509
  })
510
+
511
+ it('skips dotted-name children of nested object fields', () => {
512
+ // Repro for v1.19.1 bug: OpenAPIConnector flattens one level of nested
513
+ // object props as dotted keys for runtime metadata. The interface emitter
514
+ // must not write those keys at the top level — they're invalid TS syntax,
515
+ // and the parent object field already carries Record<string, unknown>.
516
+ const result = generateEntityInterface('commands', {
517
+ id: { name: 'id', type: 'uuid' },
518
+ filter: { name: 'filter', type: 'object' },
519
+ 'filter.botUuids': { name: 'filter.botUuids', type: 'array' },
520
+ 'filter.tags': { name: 'filter.tags', type: 'array' }
521
+ }, 'id')
522
+
523
+ expect(result).toContain('filter?: Record<string, unknown> | null')
524
+ expect(result).not.toContain('filter.botUuids')
525
+ expect(result).not.toContain('filter.tags')
526
+ expect(result).not.toMatch(/^\s*filter\./m)
527
+ })
501
528
  })
@@ -88,6 +88,12 @@ export function fieldTypeToTsType(type: UnifiedFieldType, required: boolean): st
88
88
  return 'unknown[]'
89
89
  case 'object':
90
90
  return 'Record<string, unknown>'
91
+ default:
92
+ // Fallback for types widened beyond UnifiedFieldType at runtime
93
+ // (e.g. OpenAPI oneOf/discriminator that FieldMapper couldn't classify).
94
+ // Without this, the IIFE returns undefined and we'd emit the literal
95
+ // string "undefined" into the generated .ts file.
96
+ return 'unknown'
91
97
  }
92
98
  })()
93
99
  return required ? tsType : `${tsType} | null`
@@ -107,6 +113,12 @@ export function generateEntityInterface(
107
113
 
108
114
  for (const [fieldName, field] of Object.entries(fields)) {
109
115
  if (field.hidden) continue
116
+ // Connectors (e.g. OpenAPIConnector) flatten one level of nested object
117
+ // properties as dotted keys ("filter.botUuids") for runtime form/column
118
+ // metadata. The parent object field is already typed as
119
+ // Record<string, unknown>, and dotted keys are not legal at the top level
120
+ // of a TS interface body — skip them here.
121
+ if (fieldName.includes('.')) continue
110
122
  const isRequired = field.required || fieldName === idField
111
123
  const tsType = fieldTypeToTsType(field.type, isRequired)
112
124
  const optional = isRequired ? '' : '?'