qdadm 1.13.0 → 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 +8 -4
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "1.13.0",
3
+ "version": "1.19.2",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@
36
36
  "./styles": "./src/styles/index.scss",
37
37
  "./styles/variables": "./src/styles/_variables.scss",
38
38
  "./gen": "./src/gen/index.ts",
39
- "./gen/vite-plugin": "./src/gen/vite-plugin.ts"
39
+ "./gen/vite-plugin": "./src/gen/vite-plugin.ts",
40
+ "./vite-plugin-debug": "./src/vite/qdadmDebugPlugin.ts"
40
41
  },
41
42
  "files": [
42
43
  "src",
@@ -44,11 +45,14 @@
44
45
  "LICENSE"
45
46
  ],
46
47
  "dependencies": {
48
+ "@quazardous/qdcore": "^0.2.1",
49
+ "@quazardous/qddebug": "^0.2.1",
47
50
  "@quazardous/quarkernel": "^2.1.0",
48
- "pluralize": "^8.0.0"
51
+ "pluralize": "^8.0.0",
52
+ "yaml": "^2.8.4"
49
53
  },
50
54
  "peerDependencies": {
51
- "pinia": "^2.0.0",
55
+ "pinia": "^2.0.0 || ^3.0.0",
52
56
  "primevue": "^4.0.0",
53
57
  "vue": "^3.3.0",
54
58
  "vue-router": "^4.0.0"
@@ -1,31 +1,40 @@
1
1
  /**
2
- * ActiveStack - Sync navigation context from route
2
+ * ActiveStack - Sync navigation context from route (qdadm flavour).
3
3
  *
4
- * Pure vanilla JS container using SignalBus for events.
5
- * Rebuilt from route.meta on navigation.
4
+ * Composes a generic `Stack<EntityStackLevel>` from `@quazardous/qdcore` and
5
+ * adds qdadm-specific lookups (e.g. `getLevelByEntity`). The underlying levels
6
+ * dual-carry `name` (qdcore-aligned) and `entity` (legacy qdadm field), so all
7
+ * existing consumers continue to read `level.entity` unchanged.
6
8
  *
7
9
  * Stack only contains levels WITH IDs (entities with context).
8
10
  * - /bots/bot-xyz/commands → stack = [bots(id:bot-xyz)]
9
11
  * - /bots/bot-xyz/commands/cmd-123 → stack = [bots(id:bot-xyz), commands(id:cmd-123)]
10
12
  *
11
- * Signals emitted:
12
- * - stack:change - when stack levels change
13
+ * Signals emitted (via SignalBus):
14
+ * - stack:change when stack levels change
13
15
  *
14
- * For Vue reactivity, use useActiveStack composable.
16
+ * For Vue reactivity, use the `useActiveStack` composable.
15
17
  *
16
18
  * @example
17
19
  * const stack = new ActiveStack(signalBus)
18
20
  * signalBus.on('stack:change', ({ levels }) => console.log('Stack:', levels))
19
- * stack.set([{ entity: 'bots', param: 'uuid', id: 'bot-123' }])
21
+ * stack.set([{ entity: 'bots', param: 'uuid', foreignKey: null, id: 'bot-123' }])
20
22
  */
21
23
 
24
+ import { Stack, type ContentStackLevel } from '@quazardous/qdcore'
22
25
  import type { SignalBus } from '../kernel/SignalBus'
23
26
 
24
27
  /**
25
- * Stack level definition
28
+ * qdadm stack level: entity-bound `ContentStackLevel`.
29
+ *
30
+ * `name` and `entity` always carry the same value. `name` is the qdcore-canonical
31
+ * field; `entity` is preserved for backwards compatibility with qdadm callers.
26
32
  */
27
- export interface StackLevel {
28
- /** Entity name (e.g., 'bots', 'commands') */
33
+ export interface EntityStackLevel extends ContentStackLevel {
34
+ type: 'entity'
35
+ /** qdcore-aligned name (= entity) */
36
+ name: string
37
+ /** Entity name (e.g., 'bots', 'commands') — alias of `name` */
29
38
  entity: string
30
39
  /** Route param name (e.g., 'uuid', 'id') */
31
40
  param: string
@@ -35,138 +44,110 @@ export interface StackLevel {
35
44
  id: string | null
36
45
  }
37
46
 
47
+ /**
48
+ * Legacy alias kept for backwards compatibility. New code should prefer
49
+ * {@link EntityStackLevel}.
50
+ */
51
+ export type StackLevel = EntityStackLevel
52
+
53
+ /**
54
+ * Input shape accepted by {@link ActiveStack.set} — `name` is optional and
55
+ * defaults to `entity` so existing callers (`{ entity, param, foreignKey, id }`)
56
+ * keep working without changes.
57
+ */
58
+ export type EntityStackLevelInput = Omit<EntityStackLevel, 'type' | 'name'> & {
59
+ type?: 'entity'
60
+ name?: string
61
+ }
62
+
38
63
  /**
39
64
  * Stack change event payload
40
65
  */
41
66
  export interface StackChangePayload {
42
- levels: StackLevel[]
67
+ levels: EntityStackLevel[]
68
+ }
69
+
70
+ /**
71
+ * Normalize loose input into a fully-typed EntityStackLevel.
72
+ */
73
+ function normalize(input: EntityStackLevelInput): EntityStackLevel {
74
+ return {
75
+ type: 'entity',
76
+ name: input.name ?? input.entity,
77
+ entity: input.entity,
78
+ param: input.param,
79
+ foreignKey: input.foreignKey,
80
+ id: input.id,
81
+ }
43
82
  }
44
83
 
45
84
  export class ActiveStack {
46
- private _levels: StackLevel[] = []
47
- private _signalBus: SignalBus | null
85
+ private _stack: Stack<EntityStackLevel>
48
86
 
49
87
  /**
50
88
  * @param signalBus - Optional signal bus for events
51
89
  */
52
90
  constructor(signalBus: SignalBus | null = null) {
53
- this._signalBus = signalBus
91
+ this._stack = new Stack<EntityStackLevel>(signalBus)
54
92
  }
55
93
 
56
- // ═══════════════════════════════════════════════════════════════════════════
57
- // Mutators
58
- // ═══════════════════════════════════════════════════════════════════════════
59
-
60
- /**
61
- * Replace entire stack (called on route change)
62
- * Only emits if levels actually changed (prevents duplicate emissions)
63
- *
64
- * BUG: Still getting duplicate signals (4 instead of 2) on navigation.
65
- * Equality check should prevent this but something is triggering multiple calls
66
- * with different levels. Need to investigate router.afterEach timing.
67
- */
68
- set(levels: StackLevel[]): void {
69
- // Quick equality check - same length and same entity+id pairs
70
- if (this._levelsEqual(levels)) {
71
- return
72
- }
73
- this._levels = levels
74
- this._emit('stack:change', { levels: this._levels })
75
- }
94
+ // ─── Mutators ─────────────────────────────────────────────────────────────
76
95
 
77
96
  /**
78
- * Check if new levels match current levels
79
- * @private
97
+ * Replace entire stack (called on route change).
98
+ * Only emits if levels actually changed (handled by qdcore Stack).
99
+ * Accepts either fully-typed `EntityStackLevel` or the legacy
100
+ * `{ entity, param, foreignKey, id }` shape.
80
101
  */
81
- private _levelsEqual(newLevels: StackLevel[]): boolean {
82
- if (this._levels.length !== newLevels.length) return false
83
- for (let i = 0; i < this._levels.length; i++) {
84
- const curr = this._levels[i]
85
- const next = newLevels[i]
86
- // Both are guaranteed to exist because we checked lengths match
87
- if (!curr || !next) continue
88
- if (curr.entity !== next.entity || curr.id !== next.id) {
89
- return false
90
- }
91
- }
92
- return true
102
+ set(levels: EntityStackLevelInput[]): void {
103
+ this._stack.set(levels.map(normalize))
93
104
  }
94
105
 
95
106
  /**
96
- * Clear the stack
97
- * Only emits if not already empty
107
+ * Clear the stack.
98
108
  */
99
109
  clear(): void {
100
- if (this._levels.length === 0) return
101
- this._levels = []
102
- this._emit('stack:change', { levels: this._levels })
110
+ this._stack.clear()
103
111
  }
104
112
 
105
- // ═══════════════════════════════════════════════════════════════════════════
106
- // Accessors
107
- // ═══════════════════════════════════════════════════════════════════════════
113
+ // ─── Accessors ────────────────────────────────────────────────────────────
108
114
 
109
- /**
110
- * All stack levels
111
- */
112
- getLevels(): StackLevel[] {
113
- return this._levels
115
+ getLevels(): EntityStackLevel[] {
116
+ return this._stack.getLevels()
114
117
  }
115
118
 
116
- /**
117
- * Get level by index
118
- */
119
- getLevel(index: number): StackLevel | null {
120
- return this._levels[index] ?? null
119
+ getLevel(index: number): EntityStackLevel | null {
120
+ return this._stack.getLevel(index)
121
121
  }
122
122
 
123
123
  /**
124
- * Get level by entity name
124
+ * Get level by entity name (qdadm-specific helper).
125
125
  */
126
- getLevelByEntity(entity: string): StackLevel | null {
127
- return this._levels.find(l => l.entity === entity) ?? null
126
+ getLevelByEntity(entity: string): EntityStackLevel | null {
127
+ return this._stack.getLevels().find(l => l.entity === entity) ?? null
128
128
  }
129
129
 
130
- /**
131
- * Current (deepest) level
132
- */
133
- getCurrent(): StackLevel | null {
134
- return this._levels.at(-1) ?? null
130
+ getCurrent(): EntityStackLevel | null {
131
+ return this._stack.getCurrent()
135
132
  }
136
133
 
137
- /**
138
- * Parent level (one above current)
139
- */
140
- getParent(): StackLevel | null {
141
- return this._levels.at(-2) ?? null
134
+ getParent(): EntityStackLevel | null {
135
+ return this._stack.getParent()
142
136
  }
143
137
 
144
- /**
145
- * Root level (first/topmost)
146
- */
147
- getRoot(): StackLevel | null {
148
- return this._levels[0] ?? null
138
+ getRoot(): EntityStackLevel | null {
139
+ return this._stack.getRoot()
149
140
  }
150
141
 
151
- /**
152
- * Stack depth
153
- */
154
142
  getDepth(): number {
155
- return this._levels.length
143
+ return this._stack.getDepth()
156
144
  }
157
145
 
158
- // ═══════════════════════════════════════════════════════════════════════════
159
- // Internal
160
- // ═══════════════════════════════════════════════════════════════════════════
161
-
162
146
  /**
163
- * Emit event via SignalBus
164
- * @private
147
+ * Underlying qdcore Stack instance (escape hatch for advanced use).
165
148
  */
166
- private _emit(signal: string, payload: StackChangePayload): void {
167
- if (this._signalBus) {
168
- this._signalBus.emit(signal, payload)
169
- }
149
+ getCoreStack(): Stack<EntityStackLevel> {
150
+ return this._stack
170
151
  }
171
152
  }
172
153
 
@@ -14,7 +14,8 @@
14
14
  * signalBus.on('stack:hydrated', (event) => console.log('Hydrated:', event.data.levels))
15
15
  */
16
16
 
17
- import type { ActiveStack, StackLevel } from './ActiveStack'
17
+ import type { Hydrator } from '@quazardous/qdcore'
18
+ import type { ActiveStack, EntityStackLevel, StackLevel } from './ActiveStack'
18
19
  import type { Orchestrator } from '../orchestrator/Orchestrator'
19
20
  import type { SignalBus } from '../kernel/SignalBus'
20
21
  import type { EntityRecord } from '../types'
@@ -52,7 +53,7 @@ export interface HydratedPayload {
52
53
  levels: HydratedLevel[]
53
54
  }
54
55
 
55
- export class StackHydrator {
56
+ export class StackHydrator implements Hydrator<EntityStackLevel, HydratedLevel> {
56
57
  private _activeStack: ActiveStack
57
58
  private _orchestrator: Orchestrator
58
59
  private _signalBus: SignalBus | null
@@ -11,7 +11,13 @@
11
11
  */
12
12
 
13
13
  // Sync stack (context only)
14
- export { ActiveStack, type StackLevel, type StackChangePayload } from './ActiveStack'
14
+ export {
15
+ ActiveStack,
16
+ type StackLevel,
17
+ type EntityStackLevel,
18
+ type EntityStackLevelInput,
19
+ type StackChangePayload,
20
+ } from './ActiveStack'
15
21
  export { useActiveStack, type UseActiveStackReturn } from './useActiveStack'
16
22
 
17
23
  // Async hydration (data + labels)
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * QdadmRoot — drop-in helper for hosts that own their own Vue app
4
+ * and want qdadm's DOM extras (Toast, ToastListener, DebugBar)
5
+ * rendered alongside.
6
+ *
7
+ * Equivalent to the wrapper Kernel produces internally when it owns
8
+ * `createApp`. When `Kernel({ existingApp })` is used, the host's
9
+ * App.vue should render `<QdadmRoot />` somewhere visible (typically
10
+ * at the end of the template) so toasts and the debug bar appear.
11
+ *
12
+ * The Toast / DebugBar children are conditional on what the Kernel
13
+ * was configured with (PrimeVue presence, debugBar option) — render
14
+ * is a no-op otherwise, so it's always safe to include.
15
+ */
16
+
17
+ import { computed, inject, type Component } from 'vue'
18
+ import Toast from 'primevue/toast'
19
+ import ToastListener from '../toast/ToastListener.vue'
20
+ import { getQdadmDebugBarRef } from '../kernel/Kernel.vue'
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ /**
25
+ * Render the qdadm debug bar inline. Default true preserves the
26
+ * legacy behaviour. Set false in hosts that already render their
27
+ * own (shared) `<DebugBar />` to avoid two bars on screen.
28
+ */
29
+ debugBar?: boolean
30
+ }>(),
31
+ { debugBar: true },
32
+ )
33
+
34
+ // Read the singleton-on-window debug bar ref. Using a getter (instead
35
+ // of importing the module-level const) makes this resilient to Vite
36
+ // HMR's module fragmentation in dev, where a top-level `const ref`
37
+ // can be torn into separate instances per importer.
38
+ const debugBarRef = getQdadmDebugBarRef()
39
+ const debugBarComp = computed<Component | null>(() =>
40
+ props.debugBar ? debugBarRef.value : null,
41
+ )
42
+
43
+ // `qdadmHasPrimeVue` is provided by the Kernel during _installPlugins.
44
+ // True when PrimeVue is wired (so Toast / ToastListener are usable).
45
+ const hasToast = inject<boolean>('qdadmHasPrimeVue', false)
46
+ </script>
47
+
48
+ <template>
49
+ <Toast v-if="hasToast" />
50
+ <ToastListener v-if="hasToast" />
51
+ <component v-if="debugBarComp" :is="debugBarComp" />
52
+ </template>
@@ -14,6 +14,9 @@
14
14
  */
15
15
 
16
16
  import Button from 'primevue/button'
17
+ import { useI18n } from '../../i18n/useI18n'
18
+
19
+ const { t } = useI18n()
17
20
 
18
21
  interface Props {
19
22
  isEdit?: boolean
@@ -41,31 +44,31 @@ const emit = defineEmits<{
41
44
  <div class="form-actions-left">
42
45
  <Button
43
46
  type="button"
44
- :label="isEdit ? 'Update' : 'Create'"
47
+ :label="isEdit ? t('core.actions.update') : t('core.actions.create')"
45
48
  :loading="saving"
46
49
  :disabled="!dirty || saving"
47
50
  icon="pi pi-check"
48
51
  @click="emit('save')"
49
- v-tooltip.top="'Save and continue editing'"
52
+ v-tooltip.top="t('core.tooltips.saveAndContinue')"
50
53
  />
51
54
  <Button
52
55
  v-if="showSaveAndClose"
53
56
  type="button"
54
- :label="isEdit ? 'Update & Close' : 'Create & Close'"
57
+ :label="isEdit ? t('core.actions.updateAndClose') : t('core.actions.createAndClose')"
55
58
  :loading="saving"
56
59
  :disabled="!dirty || saving"
57
60
  icon="pi pi-check-circle"
58
61
  severity="success"
59
62
  @click="emit('saveAndClose')"
60
- v-tooltip.top="'Save and return to list'"
63
+ v-tooltip.top="t('core.tooltips.saveAndReturn')"
61
64
  />
62
- <span v-if="dirty" class="dirty-indicator" v-tooltip.top="'Unsaved changes'">
65
+ <span v-if="dirty" class="dirty-indicator" v-tooltip.top="t('core.tooltips.unsavedChanges')">
63
66
  <i class="pi pi-circle-fill"></i>
64
67
  </span>
65
68
  </div>
66
69
  <Button
67
70
  type="button"
68
- label="Cancel"
71
+ :label="t('core.actions.cancel')"
69
72
  severity="secondary"
70
73
  icon="pi pi-times"
71
74
  @click="emit('cancel')"
@@ -13,6 +13,9 @@ import DataTable from 'primevue/datatable'
13
13
  import Column from 'primevue/column'
14
14
  import InputText from 'primevue/inputtext'
15
15
  import Button from 'primevue/button'
16
+ import { useI18n } from '../../i18n/useI18n'
17
+
18
+ const { t } = useI18n()
16
19
 
17
20
  export interface LookupColumn {
18
21
  field: string
@@ -163,7 +166,7 @@ const confirmLabel = computed(() => {
163
166
  <i class="pi pi-search" />
164
167
  <InputText
165
168
  v-model="searchQuery"
166
- placeholder="Search..."
169
+ :placeholder="t('core.placeholders.search')"
167
170
  class="w-full"
168
171
  autofocus
169
172
  />
@@ -199,14 +202,14 @@ const confirmLabel = computed(() => {
199
202
  />
200
203
  <template #empty>
201
204
  <div class="text-center text-color-secondary py-4">
202
- {{ searchQuery ? 'No matching items' : 'No items available' }}
205
+ {{ searchQuery ? t('core.messages.noMatching') : t('core.messages.empty') }}
203
206
  </div>
204
207
  </template>
205
208
  </DataTable>
206
209
 
207
210
  <template #footer>
208
211
  <Button
209
- label="Cancel"
212
+ :label="t('core.actions.cancel')"
210
213
  severity="secondary"
211
214
  @click="onCancel"
212
215
  />
@@ -75,3 +75,9 @@ export { default as BannerZone } from './display/BannerZone.vue'
75
75
  // Pages
76
76
  export { default as LoginPage } from './pages/LoginPage.vue'
77
77
  export { default as NotFoundPage } from './pages/NotFoundPage.vue'
78
+
79
+ // Host integration helper — render in a host App.vue when using
80
+ // `Kernel({ existingApp })` so qdadm's DOM extras (Toast,
81
+ // ToastListener, DebugBar) appear without the Kernel-managed
82
+ // WrappedRoot.
83
+ export { default as QdadmRoot } from './QdadmRoot.vue'
@@ -501,6 +501,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
501
501
  const fieldManager = useFieldManager<ResolvedFieldConfig>({
502
502
  resolveFieldConfig,
503
503
  getSchemaFieldConfig: (name) => manager.getFieldConfig(name) || null,
504
+ entity,
504
505
  })
505
506
 
506
507
  // Expose refs for validation (which needs direct access)
@@ -431,6 +431,7 @@ export function useEntityItemShowPage<T = unknown>(
431
431
  const fieldManager = useFieldManager<ResolvedFieldConfig>({
432
432
  resolveFieldConfig,
433
433
  getSchemaFieldConfig: (name) => manager.getFieldConfig?.(name) || null,
434
+ entity,
434
435
  })
435
436
 
436
437
  // Direct access to computed values
@@ -18,7 +18,8 @@
18
18
  * fieldManager.group('info', ['name', 'email'], { label: 'Information' })
19
19
  * ```
20
20
  */
21
- import { ref, computed, type Ref, type ComputedRef } from 'vue'
21
+ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
22
+ import { useI18n } from '../i18n/useI18n'
22
23
 
23
24
  /**
24
25
  * Base field definition (minimal common interface)
@@ -155,6 +156,15 @@ export interface UseFieldManagerOptions<T extends ResolvedFieldConfig = Resolved
155
156
  resolveFieldConfig?: FieldResolver<T>
156
157
  /** Get field config from schema (e.g., manager.getFieldConfig) */
157
158
  getSchemaFieldConfig?: (name: string) => Partial<BaseFieldDefinition> | null
159
+ /**
160
+ * Entity name. When set, field and group labels are resolved through
161
+ * `entities.{entity}.fields.{name}` / `entities.{entity}.groups.{name}` via
162
+ * i18n. The result wins over inline `label:` props if found; otherwise the
163
+ * inline label (then `snakeCaseToTitle`) is used. Required for the i18n
164
+ * forcing function — modules without an entity context fall back to legacy
165
+ * behaviour.
166
+ */
167
+ entity?: string
158
168
  }
159
169
 
160
170
  /**
@@ -189,6 +199,13 @@ export interface UseFieldManagerReturn<T extends ResolvedFieldConfig = ResolvedF
189
199
  hasField: (name: string) => boolean
190
200
  hasGroup: (name: string) => boolean
191
201
  getUngroupedFields: () => T[]
202
+
203
+ /**
204
+ * Re-resolve labels for all fields and groups against the active locale.
205
+ * Called automatically when `i18n.locale` changes; exposed for manual
206
+ * triggering after asynchronous bundle loads.
207
+ */
208
+ relabel: () => void
192
209
  }
193
210
 
194
211
  /**
@@ -224,10 +241,41 @@ export function useFieldManager<T extends ResolvedFieldConfig = ResolvedFieldCon
224
241
  options: UseFieldManagerOptions<T> = {}
225
242
  ): UseFieldManagerReturn<T> {
226
243
  const {
227
- resolveFieldConfig = defaultResolveFieldConfig as FieldResolver<T>,
244
+ resolveFieldConfig: userResolveFieldConfig = defaultResolveFieldConfig as FieldResolver<T>,
228
245
  getSchemaFieldConfig,
246
+ entity: entityName,
229
247
  } = options
230
248
 
249
+ // i18n integration — looked up from the kernel injection. Returns a no-op
250
+ // shim if no kernel i18n is available (tests, isolated examples).
251
+ const { i18n, locale: i18nLocale } = useI18n()
252
+
253
+ /**
254
+ * Wrapped resolver: runs the user's resolver, then overlays the i18n label
255
+ * if the convention key resolves to a real translation. Tags the resolved
256
+ * config with `_labelKey` so relabel() can re-resolve on locale change.
257
+ */
258
+ function resolveFieldConfig(
259
+ name: string,
260
+ fieldConfig: Partial<BaseFieldDefinition>
261
+ ): T {
262
+ const resolved = userResolveFieldConfig(name, fieldConfig) as T & {
263
+ _labelKey?: string
264
+ _inlineLabel?: string
265
+ }
266
+ if (!entityName || !i18n) return resolved
267
+ const key = `entities.${entityName}.fields.${name}`
268
+ resolved._labelKey = key
269
+ // Preserve the inline label (if the user supplied one) so relabel() can
270
+ // fall back to it when the i18n bundle has no matching key.
271
+ resolved._inlineLabel = fieldConfig.label as string | undefined
272
+ const trace = i18n.resolve(key)
273
+ if (trace.hit) {
274
+ resolved.label = trace.result
275
+ }
276
+ return resolved
277
+ }
278
+
231
279
  // ============ FIELD STORAGE ============
232
280
 
233
281
  const fieldsMap = ref<Map<string, T>>(new Map()) as Ref<Map<string, T>>
@@ -405,9 +453,17 @@ export function useFieldManager<T extends ResolvedFieldConfig = ResolvedFieldCon
405
453
 
406
454
  // Create or update group
407
455
  const existing = groupsMap.value.get(name)
456
+ // Resolve label: i18n hit (entities.X.groups.{name}) wins; fall back to
457
+ // explicit option, then existing label, then humanized form.
458
+ let resolvedLabel =
459
+ groupOptions?.label || existing?.label || snakeCaseToTitle(groupName)
460
+ if (entityName && i18n) {
461
+ const trace = i18n.resolve(`entities.${entityName}.groups.${name}`)
462
+ if (trace.hit) resolvedLabel = trace.result
463
+ }
408
464
  groupsMap.value.set(name, {
409
465
  name,
410
- label: groupOptions?.label || existing?.label || snakeCaseToTitle(groupName),
466
+ label: resolvedLabel,
411
467
  fields: [...(existing?.fields || []), ...fieldNames],
412
468
  parent: parentName,
413
469
  // Tab/accordion options (preserve existing if not specified)
@@ -558,6 +614,44 @@ export function useFieldManager<T extends ResolvedFieldConfig = ResolvedFieldCon
558
614
  return rootGroups
559
615
  })
560
616
 
617
+ // ============ I18N RELABEL ============
618
+
619
+ /**
620
+ * Re-resolve labels for all fields and groups against the active locale.
621
+ * Iterates the existing maps and replaces each entry's label using the
622
+ * stored convention key, falling back to the inline label or
623
+ * snakeCaseToTitle when the key has no translation.
624
+ */
625
+ function relabel(): void {
626
+ if (!entityName || !i18n) return
627
+
628
+ for (const [name, field] of fieldsMap.value.entries()) {
629
+ const meta = field as T & { _labelKey?: string; _inlineLabel?: string }
630
+ const key = meta._labelKey ?? `entities.${entityName}.fields.${name}`
631
+ const trace = i18n.resolve(key)
632
+ const newLabel = trace.hit
633
+ ? trace.result
634
+ : meta._inlineLabel || snakeCaseToTitle(name)
635
+ if (field.label !== newLabel) {
636
+ fieldsMap.value.set(name, { ...field, label: newLabel } as T)
637
+ }
638
+ }
639
+
640
+ for (const [name, internal] of groupsMap.value.entries()) {
641
+ const key = `entities.${entityName}.groups.${name}`
642
+ const trace = i18n.resolve(key)
643
+ if (trace.hit && internal.label !== trace.result) {
644
+ groupsMap.value.set(name, { ...internal, label: trace.result })
645
+ }
646
+ }
647
+ }
648
+
649
+ if (entityName && i18n) {
650
+ // Auto-relabel on locale change. Vue's watcher tracks the ref returned
651
+ // from useI18n(), so this fires for both immediate and async switches.
652
+ watch(i18nLocale, () => relabel())
653
+ }
654
+
561
655
  // ============ RETURN ============
562
656
 
563
657
  const returnValue: UseFieldManagerReturn<T> = {
@@ -589,6 +683,9 @@ export function useFieldManager<T extends ResolvedFieldConfig = ResolvedFieldCon
589
683
  hasField,
590
684
  hasGroup,
591
685
  getUngroupedFields,
686
+
687
+ // i18n
688
+ relabel,
592
689
  }
593
690
 
594
691
  return returnValue