qdadm 0.16.0 → 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 (58) hide show
  1. package/README.md +153 -1
  2. package/package.json +15 -2
  3. package/src/components/forms/FormField.vue +64 -6
  4. package/src/components/forms/FormPage.vue +276 -0
  5. package/src/components/index.js +11 -0
  6. package/src/components/layout/BaseLayout.vue +183 -0
  7. package/src/components/layout/DashboardLayout.vue +100 -0
  8. package/src/components/layout/FormLayout.vue +261 -0
  9. package/src/components/layout/ListLayout.vue +334 -0
  10. package/src/components/layout/Zone.vue +165 -0
  11. package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
  12. package/src/components/layout/defaults/DefaultFooter.vue +56 -0
  13. package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
  14. package/src/components/layout/defaults/DefaultHeader.vue +69 -0
  15. package/src/components/layout/defaults/DefaultMenu.vue +197 -0
  16. package/src/components/layout/defaults/DefaultPagination.vue +79 -0
  17. package/src/components/layout/defaults/DefaultTable.vue +130 -0
  18. package/src/components/layout/defaults/DefaultToaster.vue +16 -0
  19. package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
  20. package/src/components/layout/defaults/index.js +17 -0
  21. package/src/composables/index.js +6 -6
  22. package/src/composables/useForm.js +135 -0
  23. package/src/composables/useFormPageBuilder.js +1154 -0
  24. package/src/composables/useHooks.js +53 -0
  25. package/src/composables/useLayoutResolver.js +260 -0
  26. package/src/composables/useListPageBuilder.js +336 -52
  27. package/src/composables/useNavigation.js +38 -2
  28. package/src/composables/useSignals.js +49 -0
  29. package/src/composables/useZoneRegistry.js +162 -0
  30. package/src/core/bundles.js +406 -0
  31. package/src/core/decorator.js +322 -0
  32. package/src/core/extension.js +386 -0
  33. package/src/core/index.js +28 -0
  34. package/src/entity/EntityManager.js +314 -16
  35. package/src/entity/auth/AuthAdapter.js +125 -0
  36. package/src/entity/auth/PermissiveAdapter.js +64 -0
  37. package/src/entity/auth/index.js +11 -0
  38. package/src/entity/index.js +3 -0
  39. package/src/entity/storage/MockApiStorage.js +349 -0
  40. package/src/entity/storage/SdkStorage.js +478 -0
  41. package/src/entity/storage/index.js +2 -0
  42. package/src/hooks/HookRegistry.js +411 -0
  43. package/src/hooks/index.js +12 -0
  44. package/src/index.js +9 -0
  45. package/src/kernel/Kernel.js +136 -4
  46. package/src/kernel/SignalBus.js +180 -0
  47. package/src/kernel/index.js +7 -0
  48. package/src/module/moduleRegistry.js +124 -6
  49. package/src/orchestrator/Orchestrator.js +73 -1
  50. package/src/zones/ZoneRegistry.js +821 -0
  51. package/src/zones/index.js +16 -0
  52. package/src/zones/zones.js +189 -0
  53. package/src/composables/useEntityTitle.js +0 -121
  54. package/src/composables/useManager.js +0 -20
  55. package/src/composables/usePageBuilder.js +0 -334
  56. package/src/composables/useStatus.js +0 -146
  57. package/src/composables/useSubEditor.js +0 -165
  58. package/src/composables/useTabSync.js +0 -110
@@ -0,0 +1,162 @@
1
+ /**
2
+ * useZoneRegistry - Access the zone registry for extensible UI composition
3
+ *
4
+ * Provides reactive access to the ZoneRegistry created by Kernel during bootstrap.
5
+ * Components can register/unregister blocks and query zones reactively.
6
+ *
7
+ * **Reactivity**: The registry is reactive. When blocks are added, removed, or modified,
8
+ * components using `getBlocks()` will automatically re-render.
9
+ *
10
+ * @returns {object} - Object with registry methods and reactive helpers
11
+ * @throws {Error} If zone registry is not available (Kernel not initialized)
12
+ *
13
+ * @example
14
+ * // Basic access to zones (reactive)
15
+ * import { useZoneRegistry } from 'qdadm'
16
+ * import { ZONES } from 'qdadm/zones'
17
+ *
18
+ * const { getBlocks, registerBlock, unregisterBlock } = useZoneRegistry()
19
+ *
20
+ * // Get blocks (reactive - component re-renders when blocks change)
21
+ * const headerBlocks = computed(() => getBlocks(ZONES.HEADER))
22
+ *
23
+ * @example
24
+ * // Register/unregister blocks at component lifecycle
25
+ * import { useZoneRegistry } from 'qdadm'
26
+ * import { ZONES } from 'qdadm/zones'
27
+ * import AdBanner from './AdBanner.vue'
28
+ *
29
+ * const { registerBlock, unregisterBlock } = useZoneRegistry()
30
+ *
31
+ * onMounted(() => {
32
+ * registerBlock(ZONES.SIDEBAR, {
33
+ * id: 'ad-banner',
34
+ * component: AdBanner,
35
+ * weight: 50,
36
+ * props: { variant: 'compact' }
37
+ * })
38
+ * })
39
+ *
40
+ * onUnmounted(() => {
41
+ * unregisterBlock(ZONES.SIDEBAR, 'ad-banner')
42
+ * })
43
+ *
44
+ * @example
45
+ * // Conditional block registration based on feature flag
46
+ * import { useZoneRegistry } from 'qdadm'
47
+ * import { watch } from 'vue'
48
+ *
49
+ * const { registerBlock, unregisterBlock } = useZoneRegistry()
50
+ *
51
+ * watch(featureEnabled, (enabled) => {
52
+ * if (enabled) {
53
+ * registerBlock('sidebar', { id: 'feature-widget', component: FeatureWidget })
54
+ * } else {
55
+ * unregisterBlock('sidebar', 'feature-widget')
56
+ * }
57
+ * }, { immediate: true })
58
+ *
59
+ * @example
60
+ * // Direct registry access
61
+ * const { registry } = useZoneRegistry()
62
+ * const zones = registry.listZones()
63
+ * console.log('Available zones:', zones)
64
+ */
65
+ import { inject, computed } from 'vue'
66
+
67
+ export function useZoneRegistry() {
68
+ const registry = inject('qdadmZoneRegistry')
69
+
70
+ if (!registry) {
71
+ throw new Error('[qdadm] Zone registry not provided. Ensure Kernel is initialized.')
72
+ }
73
+
74
+ // Get the reactive version ref for tracking changes
75
+ const version = registry.getVersionRef()
76
+
77
+ /**
78
+ * Get blocks for a zone (reactive)
79
+ *
80
+ * This function depends on the registry's version counter,
81
+ * so it will return fresh data when blocks change.
82
+ *
83
+ * @param {string} zoneName - Zone name
84
+ * @returns {BlockConfig[]} - Array of block configs
85
+ */
86
+ function getBlocks(zoneName) {
87
+ // Access version.value to create reactive dependency
88
+ // eslint-disable-next-line no-unused-expressions
89
+ version.value
90
+ return registry.getBlocks(zoneName)
91
+ }
92
+
93
+ /**
94
+ * Create a computed ref for a zone's blocks
95
+ *
96
+ * Convenience method for creating a reactive computed ref
97
+ * that updates when the zone's blocks change.
98
+ *
99
+ * @param {string} zoneName - Zone name
100
+ * @returns {import('vue').ComputedRef<BlockConfig[]>} - Computed ref of blocks
101
+ *
102
+ * @example
103
+ * const headerBlocks = useBlocks(ZONES.HEADER)
104
+ * // headerBlocks.value is reactive array
105
+ */
106
+ function useBlocks(zoneName) {
107
+ return computed(() => {
108
+ // Access version to create dependency
109
+ // eslint-disable-next-line no-unused-expressions
110
+ version.value
111
+ return registry.getBlocks(zoneName)
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Register a block in a zone
117
+ *
118
+ * Proxy to registry.registerBlock() for convenience.
119
+ *
120
+ * @param {string} zoneName - Target zone name
121
+ * @param {BlockConfig} blockConfig - Block configuration
122
+ * @returns {ZoneRegistry} - The registry (for chaining)
123
+ */
124
+ function registerBlock(zoneName, blockConfig) {
125
+ return registry.registerBlock(zoneName, blockConfig)
126
+ }
127
+
128
+ /**
129
+ * Unregister a block from a zone
130
+ *
131
+ * Proxy to registry.unregisterBlock() for convenience.
132
+ *
133
+ * @param {string} zoneName - Zone name
134
+ * @param {string} blockId - Block ID to unregister
135
+ * @returns {boolean} - True if block was found and removed
136
+ */
137
+ function unregisterBlock(zoneName, blockId) {
138
+ return registry.unregisterBlock(zoneName, blockId)
139
+ }
140
+
141
+ return {
142
+ // Direct registry access for advanced usage
143
+ registry,
144
+
145
+ // Reactive helpers
146
+ getBlocks,
147
+ useBlocks,
148
+
149
+ // Block management
150
+ registerBlock,
151
+ unregisterBlock,
152
+
153
+ // Passthrough common methods
154
+ defineZone: registry.defineZone.bind(registry),
155
+ hasBlocks: registry.hasBlocks.bind(registry),
156
+ hasZone: registry.hasZone.bind(registry),
157
+ getDefault: registry.getDefault.bind(registry),
158
+ listZones: registry.listZones.bind(registry),
159
+ getZoneInfo: registry.getZoneInfo.bind(registry),
160
+ inspect: registry.inspect.bind(registry)
161
+ }
162
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Hook Bundle Pattern for qdadm
3
+ *
4
+ * A hook bundle is a function that registers multiple related hooks to implement
5
+ * a complete feature (soft delete, audit trail, timestamps, versioning).
6
+ *
7
+ * Bundles are composable and can be applied to any entity via the hook system.
8
+ * Unlike decorators that wrap a manager instance, bundles work globally through
9
+ * hooks that react to entity lifecycle events.
10
+ *
11
+ * @example
12
+ * // Define a custom bundle
13
+ * const withMyFeature = createHookBundle('myFeature', (register, context) => {
14
+ * register('entity:presave', (event) => {
15
+ * event.data.entity.myField = 'value'
16
+ * })
17
+ * })
18
+ *
19
+ * // Apply bundles to the kernel
20
+ * const cleanup = applyBundles(kernel.hooks, [
21
+ * withSoftDelete(),
22
+ * withTimestamps(),
23
+ * withVersioning()
24
+ * ])
25
+ *
26
+ * // Later: cleanup() // Removes all bundle hooks
27
+ */
28
+
29
+ import { HOOK_PRIORITY } from '../hooks/index.js'
30
+
31
+ /**
32
+ * Create a hook bundle factory
33
+ *
34
+ * A bundle factory creates bundle instances with optional configuration.
35
+ * The bundle itself registers hooks via the provided register function.
36
+ *
37
+ * @param {string} name - Bundle name for identification and debugging
38
+ * @param {Function} setup - Setup function: (register, context) => void
39
+ * - register(hookName, handler, options) - Register a hook
40
+ * - context.target - Target entity name (or '*' for all)
41
+ * - context.options - Bundle options passed to the factory
42
+ * @returns {Function} Bundle factory: (options?) => Bundle
43
+ *
44
+ * @example
45
+ * const withAudit = createHookBundle('audit', (register, context) => {
46
+ * const { logger = console.log } = context.options
47
+ *
48
+ * register('entity:postsave', (event) => {
49
+ * logger('Saved:', event.data.entity)
50
+ * }, { priority: HOOK_PRIORITY.LOW })
51
+ * })
52
+ *
53
+ * // Usage: withAudit({ logger: myLogger })
54
+ */
55
+ export function createHookBundle(name, setup) {
56
+ if (!name || typeof name !== 'string') {
57
+ throw new Error('[createHookBundle] Bundle name must be a non-empty string')
58
+ }
59
+ if (typeof setup !== 'function') {
60
+ throw new Error('[createHookBundle] Setup must be a function')
61
+ }
62
+
63
+ // Return the bundle factory
64
+ return function bundleFactory(options = {}) {
65
+ // Return the bundle instance (applied by applyBundle)
66
+ return {
67
+ name,
68
+ options,
69
+ setup
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Apply a single bundle to a HookRegistry
76
+ *
77
+ * Registers all hooks defined by the bundle and returns a cleanup function.
78
+ *
79
+ * @param {HookRegistry} hooks - HookRegistry instance
80
+ * @param {object} bundle - Bundle instance from bundle factory
81
+ * @param {object} [context] - Additional context
82
+ * @param {string} [context.target='*'] - Target entity name (or '*' for all)
83
+ * @returns {Function} Cleanup function to remove all bundle hooks
84
+ *
85
+ * @example
86
+ * const cleanup = applyBundle(kernel.hooks, withTimestamps())
87
+ * // ... later
88
+ * cleanup()
89
+ */
90
+ export function applyBundle(hooks, bundle, context = {}) {
91
+ if (!hooks || typeof hooks.register !== 'function') {
92
+ throw new Error('[applyBundle] First argument must be a HookRegistry')
93
+ }
94
+ if (!bundle || !bundle.setup) {
95
+ throw new Error('[applyBundle] Second argument must be a bundle instance')
96
+ }
97
+
98
+ const { target = '*' } = context
99
+ const unbindFns = []
100
+
101
+ // Create a register function that tracks all registrations
102
+ const register = (hookName, handler, options = {}) => {
103
+ // Prefix handler ID with bundle name for debugging
104
+ const id = options.id
105
+ ? `${bundle.name}:${options.id}`
106
+ : `${bundle.name}:${hookName.replace(/:/g, '-')}`
107
+
108
+ const unbind = hooks.register(hookName, handler, {
109
+ ...options,
110
+ id
111
+ })
112
+ unbindFns.push(unbind)
113
+ return unbind
114
+ }
115
+
116
+ // Execute bundle setup with context
117
+ bundle.setup(register, {
118
+ target,
119
+ options: bundle.options,
120
+ hooks
121
+ })
122
+
123
+ // Return cleanup function
124
+ return () => {
125
+ for (const unbind of unbindFns) {
126
+ unbind()
127
+ }
128
+ unbindFns.length = 0
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Apply multiple bundles to a HookRegistry
134
+ *
135
+ * Convenience function to apply multiple bundles at once.
136
+ *
137
+ * @param {HookRegistry} hooks - HookRegistry instance
138
+ * @param {Array} bundles - Array of bundle instances
139
+ * @param {object} [context] - Context passed to each bundle
140
+ * @returns {Function} Combined cleanup function
141
+ *
142
+ * @example
143
+ * const cleanup = applyBundles(kernel.hooks, [
144
+ * withTimestamps(),
145
+ * withSoftDelete({ field: 'deleted_at' }),
146
+ * withVersioning()
147
+ * ])
148
+ */
149
+ export function applyBundles(hooks, bundles, context = {}) {
150
+ if (!Array.isArray(bundles)) {
151
+ throw new Error('[applyBundles] Bundles must be an array')
152
+ }
153
+
154
+ const cleanupFns = bundles.map(bundle => applyBundle(hooks, bundle, context))
155
+
156
+ return () => {
157
+ for (const cleanup of cleanupFns) {
158
+ cleanup()
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * withSoftDelete bundle
165
+ *
166
+ * Prevents actual deletion by setting a deleted_at timestamp instead.
167
+ * Also provides list filtering to exclude deleted records by default.
168
+ *
169
+ * Hooks registered:
170
+ * - `entity:predelete` - Intercepts delete, sets deleted_at via patch
171
+ * - `list:alter` - Filters out deleted records (unless includeDeleted option)
172
+ *
173
+ * @param {object} [options] - Options
174
+ * @param {string} [options.field='deleted_at'] - Field name for deletion timestamp
175
+ * @param {Function} [options.timestamp] - Custom timestamp function
176
+ * @returns {object} Bundle instance
177
+ *
178
+ * @example
179
+ * applyBundle(hooks, withSoftDelete())
180
+ * applyBundle(hooks, withSoftDelete({ field: 'removed_at' }))
181
+ */
182
+ export const withSoftDelete = createHookBundle('softDelete', (register, context) => {
183
+ const {
184
+ field = 'deleted_at',
185
+ timestamp = () => new Date().toISOString()
186
+ } = context.options
187
+
188
+ // Intercept delete operations
189
+ register('entity:predelete', (event) => {
190
+ const { entity, manager, cancel } = event.data
191
+
192
+ // Get entity ID
193
+ const id = entity[manager.idField || 'id']
194
+
195
+ // Cancel the actual delete
196
+ cancel()
197
+
198
+ // Set soft-delete timestamp via patch
199
+ manager.patch(id, { [field]: timestamp() })
200
+ }, { priority: HOOK_PRIORITY.FIRST, id: 'intercept' })
201
+
202
+ // Filter deleted records from lists by default
203
+ register('list:alter', (config) => {
204
+ // Add default filter unless explicitly including deleted
205
+ if (!config.includeDeleted) {
206
+ if (!config.filters) {
207
+ config.filters = []
208
+ }
209
+ // Add filter: field is null (not deleted)
210
+ config.filters.push({
211
+ field,
212
+ operator: 'is_null',
213
+ value: true,
214
+ _bundleManaged: true
215
+ })
216
+ }
217
+ return config
218
+ }, { priority: HOOK_PRIORITY.LOW, id: 'filter-list' })
219
+ })
220
+
221
+ /**
222
+ * withTimestamps bundle
223
+ *
224
+ * Automatically manages created_at and updated_at timestamps.
225
+ *
226
+ * Hooks registered:
227
+ * - `entity:presave` - Sets created_at on new records, updated_at on all saves
228
+ *
229
+ * @param {object} [options] - Options
230
+ * @param {string} [options.createdAtField='created_at'] - Field for creation timestamp
231
+ * @param {string} [options.updatedAtField='updated_at'] - Field for update timestamp
232
+ * @param {Function} [options.timestamp] - Custom timestamp function
233
+ * @returns {object} Bundle instance
234
+ *
235
+ * @example
236
+ * applyBundle(hooks, withTimestamps())
237
+ * applyBundle(hooks, withTimestamps({ createdAtField: 'createdAt' }))
238
+ */
239
+ export const withTimestamps = createHookBundle('timestamps', (register, context) => {
240
+ const {
241
+ createdAtField = 'created_at',
242
+ updatedAtField = 'updated_at',
243
+ timestamp = () => new Date().toISOString()
244
+ } = context.options
245
+
246
+ register('entity:presave', (event) => {
247
+ const { entity, isNew } = event.data
248
+ const now = timestamp()
249
+
250
+ // Set created_at only on new records
251
+ if (isNew && !entity[createdAtField]) {
252
+ entity[createdAtField] = now
253
+ }
254
+
255
+ // Always update updated_at
256
+ entity[updatedAtField] = now
257
+ }, { priority: HOOK_PRIORITY.HIGH, id: 'set-timestamps' })
258
+ })
259
+
260
+ /**
261
+ * withVersioning bundle
262
+ *
263
+ * Implements optimistic locking via version field.
264
+ * Increments version on each save and validates version matches for updates.
265
+ *
266
+ * Hooks registered:
267
+ * - `entity:presave` - Increments version and validates for conflicts
268
+ *
269
+ * @param {object} [options] - Options
270
+ * @param {string} [options.field='version'] - Version field name
271
+ * @param {boolean} [options.validateOnUpdate=true] - Validate version on updates
272
+ * @returns {object} Bundle instance
273
+ *
274
+ * @example
275
+ * applyBundle(hooks, withVersioning())
276
+ * applyBundle(hooks, withVersioning({ field: '_version', validateOnUpdate: false }))
277
+ */
278
+ export const withVersioning = createHookBundle('versioning', (register, context) => {
279
+ const {
280
+ field = 'version',
281
+ validateOnUpdate = true
282
+ } = context.options
283
+
284
+ register('entity:presave', async (event) => {
285
+ const { entity, isNew, manager, originalEntity } = event.data
286
+
287
+ if (isNew) {
288
+ // New record: initialize version to 1
289
+ entity[field] = 1
290
+ } else {
291
+ // Existing record: validate and increment version
292
+ if (validateOnUpdate && originalEntity) {
293
+ const currentVersion = entity[field]
294
+ const originalVersion = originalEntity[field]
295
+
296
+ // Check for version conflict
297
+ if (currentVersion !== undefined && originalVersion !== undefined) {
298
+ if (currentVersion !== originalVersion) {
299
+ const error = new Error(`Version conflict: expected ${originalVersion}, got ${currentVersion}`)
300
+ error.name = 'VersionConflictError'
301
+ error.entityName = manager.name
302
+ error.expectedVersion = originalVersion
303
+ error.actualVersion = currentVersion
304
+ throw error
305
+ }
306
+ }
307
+ }
308
+
309
+ // Increment version
310
+ const currentVersion = entity[field] || 0
311
+ entity[field] = currentVersion + 1
312
+ }
313
+ }, { priority: HOOK_PRIORITY.HIGH, id: 'manage-version' })
314
+ })
315
+
316
+ /**
317
+ * withAuditLog bundle
318
+ *
319
+ * Logs all entity operations for audit trail.
320
+ *
321
+ * Hooks registered:
322
+ * - `entity:postsave` - Logs create/update operations
323
+ * - `entity:postdelete` - Logs delete operations
324
+ *
325
+ * @param {object} [options] - Options
326
+ * @param {Function} [options.logger=console.log] - Logger function
327
+ * @param {boolean} [options.includeData=false] - Include entity data in logs
328
+ * @param {boolean} [options.includeDiff=false] - Include diff for updates
329
+ * @returns {object} Bundle instance
330
+ *
331
+ * @example
332
+ * applyBundle(hooks, withAuditLog())
333
+ * applyBundle(hooks, withAuditLog({ logger: auditService.log, includeData: true }))
334
+ */
335
+ export const withAuditLog = createHookBundle('auditLog', (register, context) => {
336
+ const {
337
+ logger = console.log,
338
+ includeData = false,
339
+ includeDiff = false
340
+ } = context.options
341
+
342
+ const log = (action, details) => {
343
+ logger({
344
+ action,
345
+ timestamp: new Date().toISOString(),
346
+ ...details
347
+ })
348
+ }
349
+
350
+ register('entity:postsave', (event) => {
351
+ const { entity, isNew, manager, originalEntity } = event.data
352
+ const action = isNew ? 'create' : 'update'
353
+ const id = entity[manager.idField || 'id']
354
+
355
+ const details = {
356
+ entity: manager.name,
357
+ id
358
+ }
359
+
360
+ if (includeData) {
361
+ details.data = entity
362
+ }
363
+
364
+ if (includeDiff && !isNew && originalEntity) {
365
+ details.changes = computeDiff(originalEntity, entity)
366
+ }
367
+
368
+ log(action, details)
369
+ }, { priority: HOOK_PRIORITY.LAST, id: 'log-save' })
370
+
371
+ register('entity:postdelete', (event) => {
372
+ const { entity, manager } = event.data
373
+ const id = entity[manager.idField || 'id']
374
+
375
+ log('delete', {
376
+ entity: manager.name,
377
+ id,
378
+ ...(includeData ? { data: entity } : {})
379
+ })
380
+ }, { priority: HOOK_PRIORITY.LAST, id: 'log-delete' })
381
+ })
382
+
383
+ /**
384
+ * Compute simple diff between two objects
385
+ *
386
+ * @private
387
+ * @param {object} original - Original object
388
+ * @param {object} updated - Updated object
389
+ * @returns {object} Object with changed fields: { field: { from, to } }
390
+ */
391
+ function computeDiff(original, updated) {
392
+ const diff = {}
393
+ const allKeys = new Set([...Object.keys(original), ...Object.keys(updated)])
394
+
395
+ for (const key of allKeys) {
396
+ const oldVal = original[key]
397
+ const newVal = updated[key]
398
+
399
+ // Simple equality check (handles primitives, not deep objects)
400
+ if (oldVal !== newVal) {
401
+ diff[key] = { from: oldVal, to: newVal }
402
+ }
403
+ }
404
+
405
+ return diff
406
+ }