qdadm 0.15.1 → 0.17.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 (66) hide show
  1. package/README.md +153 -1
  2. package/package.json +15 -2
  3. package/src/components/BoolCell.vue +11 -6
  4. package/src/components/forms/FormField.vue +64 -6
  5. package/src/components/forms/FormPage.vue +276 -0
  6. package/src/components/index.js +11 -0
  7. package/src/components/layout/AppLayout.vue +18 -9
  8. package/src/components/layout/BaseLayout.vue +183 -0
  9. package/src/components/layout/DashboardLayout.vue +100 -0
  10. package/src/components/layout/FormLayout.vue +261 -0
  11. package/src/components/layout/ListLayout.vue +334 -0
  12. package/src/components/layout/PageHeader.vue +6 -9
  13. package/src/components/layout/PageNav.vue +15 -0
  14. package/src/components/layout/Zone.vue +165 -0
  15. package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
  16. package/src/components/layout/defaults/DefaultFooter.vue +56 -0
  17. package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
  18. package/src/components/layout/defaults/DefaultHeader.vue +69 -0
  19. package/src/components/layout/defaults/DefaultMenu.vue +197 -0
  20. package/src/components/layout/defaults/DefaultPagination.vue +79 -0
  21. package/src/components/layout/defaults/DefaultTable.vue +130 -0
  22. package/src/components/layout/defaults/DefaultToaster.vue +16 -0
  23. package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
  24. package/src/components/layout/defaults/index.js +17 -0
  25. package/src/composables/index.js +8 -6
  26. package/src/composables/useBreadcrumb.js +9 -5
  27. package/src/composables/useForm.js +135 -0
  28. package/src/composables/useFormPageBuilder.js +1154 -0
  29. package/src/composables/useHooks.js +53 -0
  30. package/src/composables/useLayoutResolver.js +260 -0
  31. package/src/composables/useListPageBuilder.js +336 -52
  32. package/src/composables/useNavContext.js +372 -0
  33. package/src/composables/useNavigation.js +38 -2
  34. package/src/composables/usePageTitle.js +59 -0
  35. package/src/composables/useSignals.js +49 -0
  36. package/src/composables/useZoneRegistry.js +162 -0
  37. package/src/core/bundles.js +406 -0
  38. package/src/core/decorator.js +322 -0
  39. package/src/core/extension.js +386 -0
  40. package/src/core/index.js +28 -0
  41. package/src/entity/EntityManager.js +314 -16
  42. package/src/entity/auth/AuthAdapter.js +125 -0
  43. package/src/entity/auth/PermissiveAdapter.js +64 -0
  44. package/src/entity/auth/index.js +11 -0
  45. package/src/entity/index.js +3 -0
  46. package/src/entity/storage/MockApiStorage.js +349 -0
  47. package/src/entity/storage/SdkStorage.js +478 -0
  48. package/src/entity/storage/index.js +2 -0
  49. package/src/hooks/HookRegistry.js +411 -0
  50. package/src/hooks/index.js +12 -0
  51. package/src/index.js +12 -0
  52. package/src/kernel/Kernel.js +141 -4
  53. package/src/kernel/SignalBus.js +180 -0
  54. package/src/kernel/index.js +7 -0
  55. package/src/module/moduleRegistry.js +124 -6
  56. package/src/orchestrator/Orchestrator.js +73 -1
  57. package/src/plugin.js +5 -0
  58. package/src/zones/ZoneRegistry.js +821 -0
  59. package/src/zones/index.js +16 -0
  60. package/src/zones/zones.js +189 -0
  61. package/src/composables/useEntityTitle.js +0 -121
  62. package/src/composables/useManager.js +0 -20
  63. package/src/composables/usePageBuilder.js +0 -334
  64. package/src/composables/useStatus.js +0 -146
  65. package/src/composables/useSubEditor.js +0 -165
  66. package/src/composables/useTabSync.js +0 -110
@@ -0,0 +1,180 @@
1
+ /**
2
+ * SignalBus - Wrapper around QuarKernel for qdadm event-driven architecture
3
+ *
4
+ * Provides a clean API for entity lifecycle events and cross-component communication.
5
+ * Uses QuarKernel's wildcard support for flexible event subscriptions.
6
+ *
7
+ * Signal naming conventions:
8
+ * - Generic CRUD: entity:created, entity:updated, entity:deleted
9
+ * - Entity-specific: {entityName}:created, {entityName}:updated, {entityName}:deleted
10
+ *
11
+ * Wildcard subscriptions (via QuarKernel):
12
+ * - 'entity:*' matches entity:created, entity:updated, entity:deleted
13
+ * - 'books:*' matches books:created, books:updated, books:deleted
14
+ * - '*:created' matches any entity creation
15
+ */
16
+
17
+ import { createKernel } from '@quazardous/quarkernel'
18
+
19
+ /**
20
+ * Signal names for entity operations
21
+ */
22
+ export const SIGNALS = {
23
+ // Generic entity lifecycle signals
24
+ ENTITY_CREATED: 'entity:created',
25
+ ENTITY_UPDATED: 'entity:updated',
26
+ ENTITY_DELETED: 'entity:deleted',
27
+
28
+ // Pattern for entity-specific signals
29
+ // Use buildSignal(entityName, action) for these
30
+ }
31
+
32
+ /**
33
+ * Actions for entity signals
34
+ */
35
+ export const SIGNAL_ACTIONS = {
36
+ CREATED: 'created',
37
+ UPDATED: 'updated',
38
+ DELETED: 'deleted',
39
+ }
40
+
41
+ /**
42
+ * Build an entity-specific signal name
43
+ * @param {string} entityName - Entity name (e.g., 'books', 'users')
44
+ * @param {string} action - Action from SIGNAL_ACTIONS
45
+ * @returns {string} Signal name (e.g., 'books:created')
46
+ */
47
+ export function buildSignal(entityName, action) {
48
+ return `${entityName}:${action}`
49
+ }
50
+
51
+ /**
52
+ * SignalBus class - wraps QuarKernel for qdadm
53
+ */
54
+ export class SignalBus {
55
+ /**
56
+ * @param {object} options - QuarKernel options
57
+ * @param {boolean} options.debug - Enable debug logging
58
+ */
59
+ constructor(options = {}) {
60
+ this._kernel = createKernel({
61
+ delimiter: ':',
62
+ wildcard: true,
63
+ errorBoundary: true,
64
+ debug: options.debug ?? false,
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Emit a signal with payload
70
+ * @param {string} signal - Signal name
71
+ * @param {*} payload - Signal payload (typically { entity, data, context })
72
+ * @returns {Promise<void>}
73
+ */
74
+ async emit(signal, payload) {
75
+ return this._kernel.emit(signal, payload)
76
+ }
77
+
78
+ /**
79
+ * Subscribe to a signal
80
+ * @param {string} signal - Signal name (supports wildcards via QuarKernel)
81
+ * @param {Function} handler - Handler function (event, ctx) => void
82
+ * @param {object} options - Listener options
83
+ * @param {number} options.priority - Listener priority (higher = earlier)
84
+ * @param {string} options.id - Unique listener ID
85
+ * @param {string|string[]} options.after - Run after these listener IDs
86
+ * @param {boolean} options.once - Remove after first call
87
+ * @returns {Function} Unbind function
88
+ */
89
+ on(signal, handler, options = {}) {
90
+ return this._kernel.on(signal, handler, options)
91
+ }
92
+
93
+ /**
94
+ * Unsubscribe from a signal
95
+ * @param {string} signal - Signal name
96
+ * @param {Function} handler - Handler function to remove (optional, removes all if omitted)
97
+ */
98
+ off(signal, handler) {
99
+ this._kernel.off(signal, handler)
100
+ }
101
+
102
+ /**
103
+ * Subscribe to a signal once (Promise-based)
104
+ * @param {string} signal - Signal name
105
+ * @param {object} options - Options
106
+ * @param {number} options.timeout - Timeout in ms
107
+ * @returns {Promise<object>} Event object
108
+ */
109
+ once(signal, options = {}) {
110
+ return this._kernel.once(signal, options)
111
+ }
112
+
113
+ /**
114
+ * Emit an entity lifecycle signal
115
+ * @param {string} entityName - Entity name
116
+ * @param {string} action - Action from SIGNAL_ACTIONS
117
+ * @param {object} data - Entity data
118
+ * @returns {Promise<void>}
119
+ */
120
+ async emitEntity(entityName, action, data) {
121
+ const specificSignal = buildSignal(entityName, action)
122
+ const genericSignal = buildSignal('entity', action)
123
+
124
+ // Emit both specific and generic signals
125
+ // Specific first, then generic
126
+ await this.emit(specificSignal, { entity: entityName, data })
127
+ await this.emit(genericSignal, { entity: entityName, data })
128
+ }
129
+
130
+ /**
131
+ * Get listener count for a signal
132
+ * @param {string} signal - Signal name (optional, total if omitted)
133
+ * @returns {number}
134
+ */
135
+ listenerCount(signal) {
136
+ return this._kernel.listenerCount(signal)
137
+ }
138
+
139
+ /**
140
+ * Get all registered signal names
141
+ * @returns {string[]}
142
+ */
143
+ signalNames() {
144
+ return this._kernel.eventNames()
145
+ }
146
+
147
+ /**
148
+ * Remove all listeners
149
+ * @param {string} signal - Signal name (optional, all if omitted)
150
+ */
151
+ offAll(signal) {
152
+ this._kernel.offAll(signal)
153
+ }
154
+
155
+ /**
156
+ * Enable/disable debug mode
157
+ * @param {boolean} enabled
158
+ */
159
+ debug(enabled) {
160
+ this._kernel.debug(enabled)
161
+ }
162
+
163
+ /**
164
+ * Get the underlying QuarKernel instance
165
+ * Used for sharing the kernel with HookRegistry
166
+ * @returns {QuarKernel}
167
+ */
168
+ getKernel() {
169
+ return this._kernel
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Factory function to create a SignalBus instance
175
+ * @param {object} options - SignalBus options
176
+ * @returns {SignalBus}
177
+ */
178
+ export function createSignalBus(options = {}) {
179
+ return new SignalBus(options)
180
+ }
@@ -5,3 +5,10 @@
5
5
  */
6
6
 
7
7
  export { Kernel } from './Kernel.js'
8
+ export {
9
+ SignalBus,
10
+ createSignalBus,
11
+ SIGNALS,
12
+ SIGNAL_ACTIONS,
13
+ buildSignal,
14
+ } from './SignalBus.js'
@@ -36,6 +36,13 @@ const entityConfigs = new Map() // Entity declarations from modules
36
36
  // Configurable section order (set via setSectionOrder or bootstrap)
37
37
  let sectionOrder = []
38
38
 
39
+ // Altered nav sections after menu:alter hook
40
+ // null means not yet altered, use raw navSections
41
+ let alteredNavSections = null
42
+
43
+ // Promise for in-flight alteration (prevents concurrent calls)
44
+ let alterationPromise = null
45
+
39
46
  /**
40
47
  * Registry API passed to module init functions
41
48
  */
@@ -52,9 +59,10 @@ const registry = {
52
59
  * @param {string} options.parent.foreignKey - Foreign key field (e.g., 'book_id')
53
60
  * @param {string} [options.parent.itemRoute] - Override parent item route (auto: parentEntity.routePrefix + '-edit')
54
61
  * @param {string} [options.label] - Label for navlinks (defaults to entity labelPlural)
62
+ * @param {string} [options.layout] - Default layout type for routes ('list', 'form', 'dashboard', 'base')
55
63
  */
56
64
  addRoutes(prefix, moduleRoutes, options = {}) {
57
- const { entity, parent, label } = options
65
+ const { entity, parent, label, layout } = options
58
66
  const prefixedRoutes = moduleRoutes.map(route => ({
59
67
  ...route,
60
68
  path: route.path ? `${prefix}/${route.path}` : prefix,
@@ -62,7 +70,9 @@ const registry = {
62
70
  ...route.meta,
63
71
  ...(entity && { entity }),
64
72
  ...(parent && { parent }),
65
- ...(label && { navLabel: label })
73
+ ...(label && { navLabel: label }),
74
+ // Layout can be set per-route or inherited from options
75
+ ...((route.meta?.layout || layout) && { layout: route.meta?.layout || layout })
66
76
  }
67
77
  }))
68
78
  routes.push(...prefixedRoutes)
@@ -154,9 +164,10 @@ export function getRoutes() {
154
164
  }
155
165
 
156
166
  /**
157
- * Get navigation sections in order
167
+ * Build raw navigation sections from registered nav items (before alteration)
168
+ * @returns {Array<{title: string, items: Array}>}
158
169
  */
159
- export function getNavSections() {
170
+ function buildRawNavSections() {
160
171
  const sections = []
161
172
 
162
173
  // First add sections in the configured order
@@ -164,7 +175,7 @@ export function getNavSections() {
164
175
  if (navSections.has(title)) {
165
176
  sections.push({
166
177
  title,
167
- items: navSections.get(title)
178
+ items: [...navSections.get(title)] // Clone items array
168
179
  })
169
180
  }
170
181
  }
@@ -172,13 +183,118 @@ export function getNavSections() {
172
183
  // Add any sections not in the predefined order
173
184
  for (const [title, items] of navSections) {
174
185
  if (!sectionOrder.includes(title)) {
175
- sections.push({ title, items })
186
+ sections.push({ title, items: [...items] }) // Clone items array
176
187
  }
177
188
  }
178
189
 
179
190
  return sections
180
191
  }
181
192
 
193
+ /**
194
+ * Get navigation sections in order
195
+ *
196
+ * Returns altered sections if menu:alter hook has been invoked,
197
+ * otherwise returns raw sections from module registrations.
198
+ *
199
+ * @returns {Array<{title: string, items: Array}>}
200
+ */
201
+ export function getNavSections() {
202
+ // Return altered sections if available, otherwise build from raw
203
+ if (alteredNavSections !== null) {
204
+ return alteredNavSections
205
+ }
206
+ return buildRawNavSections()
207
+ }
208
+
209
+ /**
210
+ * Invoke menu:alter hook to allow modules to modify navigation structure
211
+ *
212
+ * Called lazily by useNavigation on first access. Modules can register
213
+ * handlers for 'menu:alter' hook to add, remove, reorder, or modify
214
+ * navigation sections and items.
215
+ *
216
+ * @param {HookRegistry} hooks - The hook registry instance
217
+ * @returns {Promise<void>}
218
+ *
219
+ * @typedef {Object} MenuAlterContext
220
+ * @property {Array<MenuSection>} sections - Navigation sections (mutable)
221
+ *
222
+ * @typedef {Object} MenuSection
223
+ * @property {string} title - Section title
224
+ * @property {Array<NavItem>} items - Navigation items in this section
225
+ *
226
+ * @typedef {Object} NavItem
227
+ * @property {string} route - Route name
228
+ * @property {string} label - Display label
229
+ * @property {string} [icon] - Icon class (e.g., 'pi pi-users')
230
+ * @property {string} [entity] - Entity name for permission checks
231
+ * @property {boolean} [exact] - Use exact route matching
232
+ *
233
+ * @example
234
+ * // In module init or extension
235
+ * hooks.register('menu:alter', (context) => {
236
+ * // Add a new section
237
+ * context.sections.push({
238
+ * title: 'Tools',
239
+ * items: [{ route: 'tools', label: 'Tools', icon: 'pi pi-wrench' }]
240
+ * })
241
+ *
242
+ * // Add item to existing section
243
+ * const adminSection = context.sections.find(s => s.title === 'Admin')
244
+ * if (adminSection) {
245
+ * adminSection.items.push({ route: 'settings', label: 'Settings' })
246
+ * }
247
+ *
248
+ * // Remove a section
249
+ * const idx = context.sections.findIndex(s => s.title === 'Legacy')
250
+ * if (idx !== -1) context.sections.splice(idx, 1)
251
+ *
252
+ * return context
253
+ * })
254
+ */
255
+ export async function alterMenuSections(hooks) {
256
+ // Already altered? Return immediately
257
+ if (alteredNavSections !== null) {
258
+ return
259
+ }
260
+
261
+ // In-flight alteration? Wait for it
262
+ if (alterationPromise !== null) {
263
+ await alterationPromise
264
+ return
265
+ }
266
+
267
+ // No hooks registry? Mark as altered with raw sections
268
+ if (!hooks) {
269
+ alteredNavSections = buildRawNavSections()
270
+ return
271
+ }
272
+
273
+ // Perform alteration
274
+ alterationPromise = (async () => {
275
+ const rawSections = buildRawNavSections()
276
+
277
+ // Invoke menu:alter hook with mutable context
278
+ const alteredContext = await hooks.alter('menu:alter', {
279
+ sections: rawSections
280
+ })
281
+
282
+ // Store altered sections for getNavSections()
283
+ alteredNavSections = alteredContext.sections
284
+ alterationPromise = null
285
+ })()
286
+
287
+ await alterationPromise
288
+ }
289
+
290
+ /**
291
+ * Check if menu:alter hook has been invoked
292
+ * @returns {boolean}
293
+ */
294
+ export function isMenuAltered() {
295
+ return alteredNavSections !== null
296
+ }
297
+
182
298
  /**
183
299
  * Get route families mapping
184
300
  */
@@ -239,6 +355,8 @@ export function resetRegistry() {
239
355
  navSections.clear()
240
356
  entityConfigs.clear()
241
357
  sectionOrder = []
358
+ alteredNavSections = null
359
+ alterationPromise = null
242
360
  }
243
361
 
244
362
  // Export registry for direct access if needed
@@ -35,11 +35,20 @@ export class Orchestrator {
35
35
  const {
36
36
  entityFactory = null,
37
37
  // Optional: pre-registered managers (for special cases)
38
- managers = {}
38
+ managers = {},
39
+ // SignalBus instance for event-driven communication
40
+ signals = null,
41
+ // HookRegistry instance for lifecycle hooks
42
+ hooks = null,
43
+ // Optional: AuthAdapter for entity permission checks (scope/silo)
44
+ entityAuthAdapter = null
39
45
  } = options
40
46
 
41
47
  this._entityFactory = entityFactory
42
48
  this._managers = new Map()
49
+ this._signals = signals
50
+ this._hooks = hooks
51
+ this._entityAuthAdapter = entityAuthAdapter
43
52
 
44
53
  // Register pre-provided managers
45
54
  for (const [name, manager] of Object.entries(managers)) {
@@ -47,6 +56,56 @@ export class Orchestrator {
47
56
  }
48
57
  }
49
58
 
59
+ /**
60
+ * Set the SignalBus instance
61
+ * @param {SignalBus} signals
62
+ */
63
+ setSignals(signals) {
64
+ this._signals = signals
65
+ }
66
+
67
+ /**
68
+ * Get the SignalBus instance
69
+ * @returns {SignalBus|null}
70
+ */
71
+ get signals() {
72
+ return this._signals
73
+ }
74
+
75
+ /**
76
+ * Set the entity AuthAdapter for permission checks
77
+ * This adapter will be injected into all newly registered managers
78
+ * (unless they already have their own adapter)
79
+ * @param {AuthAdapter} adapter
80
+ */
81
+ setEntityAuthAdapter(adapter) {
82
+ this._entityAuthAdapter = adapter
83
+ }
84
+
85
+ /**
86
+ * Get the entity AuthAdapter
87
+ * @returns {AuthAdapter|null}
88
+ */
89
+ get entityAuthAdapter() {
90
+ return this._entityAuthAdapter
91
+ }
92
+
93
+ /**
94
+ * Set the HookRegistry instance
95
+ * @param {HookRegistry} hooks
96
+ */
97
+ setHooks(hooks) {
98
+ this._hooks = hooks
99
+ }
100
+
101
+ /**
102
+ * Get the HookRegistry instance
103
+ * @returns {HookRegistry|null}
104
+ */
105
+ get hooks() {
106
+ return this._hooks
107
+ }
108
+
50
109
  /**
51
110
  * Set the entity factory
52
111
  * @param {function} factory - (entityName, entityConfig) => EntityManager
@@ -64,6 +123,19 @@ export class Orchestrator {
64
123
  */
65
124
  register(name, manager) {
66
125
  this._managers.set(name, manager)
126
+ // Pass signals reference to manager for event emission
127
+ if (this._signals && manager.setSignals) {
128
+ manager.setSignals(this._signals)
129
+ }
130
+ // Pass hooks reference to manager for lifecycle hooks
131
+ if (this._hooks && manager.setHooks) {
132
+ manager.setHooks(this._hooks)
133
+ }
134
+ // Inject entityAuthAdapter if provided and manager doesn't have one
135
+ // Manager's own adapter takes precedence (allows per-entity customization)
136
+ if (this._entityAuthAdapter && !manager._authAdapter) {
137
+ manager.authAdapter = this._entityAuthAdapter
138
+ }
67
139
  if (manager.onRegister) {
68
140
  manager.onRegister(this)
69
141
  }
package/src/plugin.js CHANGED
@@ -22,6 +22,7 @@ import qdadmLogo from './assets/logo.svg'
22
22
  * @param {object} options.features - Optional: Feature toggles (auth, poweredBy)
23
23
  * @param {object} options.builtinModules - Optional: Builtin modules configuration
24
24
  * @param {object} options.endpoints - Optional: API endpoints configuration
25
+ * @param {string} options.homeRoute - Optional: Home route name for breadcrumb
25
26
  * @returns {object} Vue plugin
26
27
  */
27
28
  export function createQdadm(options) {
@@ -112,6 +113,10 @@ export function createQdadm(options) {
112
113
  app.provide('qdadmSectionOrder', options.modules.sectionOrder)
113
114
  }
114
115
 
116
+ if (options.homeRoute) {
117
+ app.provide('qdadmHomeRoute', options.homeRoute)
118
+ }
119
+
115
120
  // Add route guard for entity permissions
116
121
  options.router.beforeEach((to, from, next) => {
117
122
  const entity = to.meta?.entity