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,821 @@
1
+ /**
2
+ * ZoneRegistry - Manages named zones and block registrations
3
+ *
4
+ * Inspired by Twig/Symfony block system. Zones are named slots in layouts
5
+ * where blocks (components) can be injected with weight ordering.
6
+ *
7
+ * **Vue Reactivity**: The registry uses Vue's reactivity system internally.
8
+ * When blocks are added, removed, or modified, zones automatically trigger
9
+ * re-renders in components using them.
10
+ *
11
+ * Supports block operations:
12
+ * - 'add' (default): Simply add block to zone
13
+ * - 'replace': Substitute an existing block entirely
14
+ * - 'extend': Insert block before/after an existing block
15
+ * - 'wrap': Wrap an existing block with a decorator component
16
+ *
17
+ * Usage:
18
+ * ```js
19
+ * const registry = new ZoneRegistry()
20
+ *
21
+ * // Define zones with optional defaults
22
+ * registry.defineZone('header', { default: DefaultHeader })
23
+ * registry.defineZone('sidebar')
24
+ *
25
+ * // Register blocks with weight (lower = first)
26
+ * registry.registerBlock('header', { component: Logo, weight: 10, id: 'logo' })
27
+ * registry.registerBlock('header', { component: UserMenu, weight: 90 })
28
+ *
29
+ * // Replace a block entirely
30
+ * registry.registerBlock('header', {
31
+ * component: CustomLogo,
32
+ * operation: 'replace',
33
+ * replaces: 'logo'
34
+ * })
35
+ *
36
+ * // Extend: insert after an existing block
37
+ * registry.registerBlock('header', {
38
+ * component: ExtraMenuItem,
39
+ * operation: 'extend',
40
+ * after: 'logo'
41
+ * })
42
+ *
43
+ * // Extend: insert before an existing block
44
+ * registry.registerBlock('header', {
45
+ * component: Announcements,
46
+ * operation: 'extend',
47
+ * before: 'logo'
48
+ * })
49
+ *
50
+ * // Wrap: decorate a block with before/after content
51
+ * registry.registerBlock('header', {
52
+ * component: BorderWrapper,
53
+ * operation: 'wrap',
54
+ * wraps: 'logo'
55
+ * })
56
+ *
57
+ * // Unregister blocks at runtime
58
+ * registry.unregisterBlock('header', 'logo')
59
+ *
60
+ * // Get blocks sorted by weight
61
+ * registry.getBlocks('header') // [Announcements, CustomLogo@10, ExtraMenuItem, UserMenu@90]
62
+ * ```
63
+ */
64
+
65
+ import { shallowRef, triggerRef } from 'vue'
66
+
67
+ /**
68
+ * @typedef {'add' | 'replace' | 'extend' | 'wrap'} BlockOperation
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} BlockConfig
73
+ * @property {import('vue').Component} component - Vue component
74
+ * @property {number} [weight=50] - Ordering weight (lower = first)
75
+ * @property {object} [props={}] - Props to pass to component
76
+ * @property {string} [id] - Unique identifier for duplicate detection
77
+ * @property {BlockOperation} [operation='add'] - Block operation
78
+ * @property {string} [replaces] - Block ID to replace (required if operation='replace')
79
+ * @property {string} [before] - Block ID to insert before (for operation='extend')
80
+ * @property {string} [after] - Block ID to insert after (for operation='extend')
81
+ * @property {string} [wraps] - Block ID to wrap (required if operation='wrap')
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} ZoneConfig
86
+ * @property {import('vue').Component|null} [default=null] - Default component if no blocks
87
+ * @property {BlockConfig[]} blocks - Registered blocks
88
+ */
89
+
90
+ const DEFAULT_WEIGHT = 50
91
+
92
+ export class ZoneRegistry {
93
+ constructor() {
94
+ /**
95
+ * Zone storage: name -> ZoneConfig
96
+ * @type {Map<string, ZoneConfig>}
97
+ */
98
+ this._zones = new Map()
99
+
100
+ /**
101
+ * Cache for sorted blocks (invalidated on registerBlock)
102
+ * @type {Map<string, BlockConfig[]>}
103
+ */
104
+ this._sortedCache = new Map()
105
+
106
+ /**
107
+ * Wrap graph for cycle detection: zoneName -> Map<wrapperId, targetId>
108
+ * @type {Map<string, Map<string, string>>}
109
+ */
110
+ this._wrapGraph = new Map()
111
+
112
+ /**
113
+ * Debug mode for warnings
114
+ * @type {boolean}
115
+ */
116
+ this._debug = false
117
+
118
+ /**
119
+ * Version counter for reactivity - triggers re-renders when blocks change
120
+ * Using shallowRef for lightweight reactivity that tracks mutation count
121
+ * @type {import('vue').ShallowRef<number>}
122
+ */
123
+ this._version = shallowRef(0)
124
+ }
125
+
126
+ /**
127
+ * Get the reactive version ref for tracking changes
128
+ *
129
+ * Components can watch this ref to re-render when blocks change.
130
+ * Use `useZoneRegistry()` composable for convenient access.
131
+ *
132
+ * @returns {import('vue').ShallowRef<number>} Reactive version counter
133
+ */
134
+ getVersionRef() {
135
+ return this._version
136
+ }
137
+
138
+ /**
139
+ * Trigger Vue reactivity update
140
+ *
141
+ * Called after any mutation that should cause Zone components to re-render.
142
+ * @private
143
+ */
144
+ _triggerUpdate() {
145
+ this._version.value++
146
+ }
147
+
148
+ /**
149
+ * Enable or disable debug mode
150
+ *
151
+ * When enabled, warnings are logged for missing replace targets, etc.
152
+ *
153
+ * @param {boolean} enabled
154
+ * @returns {this} - For chaining
155
+ */
156
+ setDebug(enabled) {
157
+ this._debug = !!enabled
158
+ return this
159
+ }
160
+
161
+ /**
162
+ * Define a new zone
163
+ *
164
+ * If zone already exists, merges options (updating default if provided).
165
+ *
166
+ * @param {string} name - Zone name (e.g., 'header', 'sidebar')
167
+ * @param {object} [options={}] - Zone options
168
+ * @param {import('vue').Component} [options.default=null] - Default component when no blocks
169
+ * @returns {this} - For chaining
170
+ */
171
+ defineZone(name, options = {}) {
172
+ if (!name || typeof name !== 'string') {
173
+ throw new Error('[ZoneRegistry] Zone name must be a non-empty string')
174
+ }
175
+
176
+ const existing = this._zones.get(name)
177
+ if (existing) {
178
+ // Merge: update default if provided
179
+ if (options.default !== undefined) {
180
+ existing.default = options.default
181
+ }
182
+ } else {
183
+ this._zones.set(name, {
184
+ default: options.default ?? null,
185
+ blocks: []
186
+ })
187
+ }
188
+
189
+ return this
190
+ }
191
+
192
+ /**
193
+ * Register a block in a zone
194
+ *
195
+ * Blocks are sorted by weight (ascending). Equal weights maintain insertion order.
196
+ *
197
+ * Operations:
198
+ * - 'add' (default): Simply add the block to the zone
199
+ * - 'replace': Substitute an existing block by ID
200
+ * - 'extend': Insert block before/after an existing block
201
+ * - 'wrap': Wrap an existing block with a decorator component
202
+ *
203
+ * @param {string} zoneName - Target zone name
204
+ * @param {BlockConfig} blockConfig - Block configuration
205
+ * @returns {this} - For chaining
206
+ * @throws {Error} If component is not provided
207
+ * @throws {Error} If operation is 'replace' but replaces is not specified
208
+ * @throws {Error} If operation is 'extend' but neither before nor after is specified
209
+ * @throws {Error} If operation is 'extend' and both before and after are specified
210
+ * @throws {Error} If operation is 'wrap' but wraps is not specified
211
+ * @throws {Error} If operation is 'wrap' but id is not specified
212
+ * @throws {Error} If wrap would create a circular dependency
213
+ */
214
+ registerBlock(zoneName, blockConfig) {
215
+ if (!zoneName || typeof zoneName !== 'string') {
216
+ throw new Error('[ZoneRegistry] Zone name must be a non-empty string')
217
+ }
218
+
219
+ if (!blockConfig || !blockConfig.component) {
220
+ throw new Error('[ZoneRegistry] Block must have a component')
221
+ }
222
+
223
+ const operation = blockConfig.operation || 'add'
224
+
225
+ // Validate replace operation
226
+ if (operation === 'replace' && !blockConfig.replaces) {
227
+ throw new Error(
228
+ `[ZoneRegistry] Block with operation 'replace' must specify 'replaces' target ID`
229
+ )
230
+ }
231
+
232
+ // Validate extend operation
233
+ if (operation === 'extend') {
234
+ const hasBefore = !!blockConfig.before
235
+ const hasAfter = !!blockConfig.after
236
+
237
+ if (!hasBefore && !hasAfter) {
238
+ throw new Error(
239
+ `[ZoneRegistry] Block with operation 'extend' must specify either 'before' or 'after' target ID`
240
+ )
241
+ }
242
+ if (hasBefore && hasAfter) {
243
+ throw new Error(
244
+ `[ZoneRegistry] Block with operation 'extend' cannot specify both 'before' and 'after'`
245
+ )
246
+ }
247
+ }
248
+
249
+ // Validate wrap operation
250
+ if (operation === 'wrap') {
251
+ if (!blockConfig.wraps) {
252
+ throw new Error(
253
+ `[ZoneRegistry] Block with operation 'wrap' must specify 'wraps' target ID`
254
+ )
255
+ }
256
+ if (!blockConfig.id) {
257
+ throw new Error(
258
+ `[ZoneRegistry] Block with operation 'wrap' must have an 'id' for cycle detection`
259
+ )
260
+ }
261
+ // Check for circular wrap dependency
262
+ if (this._wouldCreateWrapCycle(zoneName, blockConfig.id, blockConfig.wraps)) {
263
+ throw new Error(
264
+ `[ZoneRegistry] Circular wrap dependency detected: "${blockConfig.id}" wrapping "${blockConfig.wraps}" would create a cycle`
265
+ )
266
+ }
267
+ }
268
+
269
+ // Auto-create zone if not exists (DX decision: reduce friction)
270
+ if (!this._zones.has(zoneName)) {
271
+ this.defineZone(zoneName)
272
+ }
273
+
274
+ const zone = this._zones.get(zoneName)
275
+
276
+ // Prepare normalized block config
277
+ const block = {
278
+ component: blockConfig.component,
279
+ weight: blockConfig.weight ?? DEFAULT_WEIGHT,
280
+ props: blockConfig.props ?? {},
281
+ id: blockConfig.id ?? null,
282
+ operation,
283
+ replaces: blockConfig.replaces ?? null,
284
+ before: blockConfig.before ?? null,
285
+ after: blockConfig.after ?? null,
286
+ wraps: blockConfig.wraps ?? null
287
+ }
288
+
289
+ // Update wrap graph for cycle detection (wrap operation only)
290
+ if (operation === 'wrap') {
291
+ if (!this._wrapGraph.has(zoneName)) {
292
+ this._wrapGraph.set(zoneName, new Map())
293
+ }
294
+ this._wrapGraph.get(zoneName).set(block.id, block.wraps)
295
+ }
296
+
297
+ // Check for duplicate ID (for 'add' operations only)
298
+ if (block.id && operation === 'add') {
299
+ const existingIndex = zone.blocks.findIndex(b => b.id === block.id)
300
+ if (existingIndex !== -1) {
301
+ // Replace existing block with same ID
302
+ zone.blocks[existingIndex] = block
303
+ } else {
304
+ zone.blocks.push(block)
305
+ }
306
+ } else {
307
+ zone.blocks.push(block)
308
+ }
309
+
310
+ // Invalidate cache for this zone
311
+ this._sortedCache.delete(zoneName)
312
+
313
+ // Trigger Vue reactivity update
314
+ this._triggerUpdate()
315
+
316
+ // Dev mode logging for block registration
317
+ if (this._debug) {
318
+ const blockDesc = block.id || '(anonymous)'
319
+ const opDesc = operation === 'add' ? '' : ` [${operation}]`
320
+ console.debug(`[qdadm:zones] Registered block in zone: ${zoneName}, ${blockDesc}${opDesc}`)
321
+ }
322
+
323
+ return this
324
+ }
325
+
326
+ /**
327
+ * Check if adding a wrap would create a cycle
328
+ *
329
+ * Detects cycles by traversing the wrap graph from the wrapper.
330
+ * If the target eventually wraps back to the wrapper, it's a cycle.
331
+ *
332
+ * @param {string} zoneName - Zone name
333
+ * @param {string} wrapperId - ID of the wrapper block being added
334
+ * @param {string} targetId - ID of the block being wrapped
335
+ * @returns {boolean} - True if adding this wrap would create a cycle
336
+ * @private
337
+ */
338
+ _wouldCreateWrapCycle(zoneName, wrapperId, targetId) {
339
+ // Self-wrap is a cycle
340
+ if (wrapperId === targetId) {
341
+ return true
342
+ }
343
+
344
+ const zoneGraph = this._wrapGraph.get(zoneName)
345
+ if (!zoneGraph) {
346
+ return false
347
+ }
348
+
349
+ // Check if the target (or anything it wraps) eventually wraps the wrapper
350
+ // Start from targetId and follow the chain to see if we reach wrapperId
351
+ const visited = new Set()
352
+ let current = targetId
353
+
354
+ while (current && !visited.has(current)) {
355
+ visited.add(current)
356
+ // What does 'current' wrap?
357
+ const wrapsWhat = zoneGraph.get(current)
358
+ if (wrapsWhat === wrapperId) {
359
+ // Found a cycle: target -> ... -> wrapper
360
+ return true
361
+ }
362
+ current = wrapsWhat
363
+ }
364
+
365
+ return false
366
+ }
367
+
368
+ /**
369
+ * Get sorted blocks for a zone
370
+ *
371
+ * Returns blocks sorted by weight (ascending) after applying all block operations.
372
+ * Uses cached result if available.
373
+ *
374
+ * Operations:
375
+ * - 'add': Block is included directly
376
+ * - 'replace': Replaces target block, inherits target's weight if not specified
377
+ * - 'extend': Inserts block before/after target, uses target's weight for positioning
378
+ * - 'wrap': Adds wrappers array to target block containing wrapper components
379
+ *
380
+ * @param {string} zoneName - Zone name
381
+ * @returns {BlockConfig[]} - Sorted array of block configs (empty if zone undefined)
382
+ */
383
+ getBlocks(zoneName) {
384
+ if (!this._zones.has(zoneName)) {
385
+ return []
386
+ }
387
+
388
+ // Return cached sorted result if available
389
+ if (this._sortedCache.has(zoneName)) {
390
+ return this._sortedCache.get(zoneName)
391
+ }
392
+
393
+ const zone = this._zones.get(zoneName)
394
+
395
+ // Separate blocks by operation
396
+ const addBlocks = []
397
+ const replaceBlocks = []
398
+ const extendBlocks = []
399
+ const wrapBlocks = []
400
+
401
+ for (const block of zone.blocks) {
402
+ if (block.operation === 'replace') {
403
+ replaceBlocks.push(block)
404
+ } else if (block.operation === 'extend') {
405
+ extendBlocks.push(block)
406
+ } else if (block.operation === 'wrap') {
407
+ wrapBlocks.push(block)
408
+ } else {
409
+ addBlocks.push(block)
410
+ }
411
+ }
412
+
413
+ // Start with add blocks
414
+ let result = [...addBlocks]
415
+
416
+ // Apply replace operations first
417
+ for (const replaceBlock of replaceBlocks) {
418
+ const targetIndex = result.findIndex(b => b.id === replaceBlock.replaces)
419
+
420
+ if (targetIndex === -1) {
421
+ // Target not found - warn but still add the replacement at its own weight
422
+ if (this._debug) {
423
+ console.warn(
424
+ `[ZoneRegistry] Replace target "${replaceBlock.replaces}" not found in zone "${zoneName}". ` +
425
+ `Adding replacement block at default weight.`
426
+ )
427
+ }
428
+ result.push(replaceBlock)
429
+ } else {
430
+ // Replace: use target's weight if replacement doesn't specify one
431
+ const targetBlock = result[targetIndex]
432
+ const hasExplicitWeight = replaceBlock.weight !== DEFAULT_WEIGHT
433
+ const finalWeight = hasExplicitWeight ? replaceBlock.weight : targetBlock.weight
434
+
435
+ const replacement = {
436
+ ...replaceBlock,
437
+ weight: finalWeight
438
+ }
439
+ result.splice(targetIndex, 1, replacement)
440
+ }
441
+ }
442
+
443
+ // Sort by weight before applying extend operations
444
+ // This ensures targets are in their final positions
445
+ result.sort((a, b) => a.weight - b.weight)
446
+
447
+ // Apply extend operations (in registration order)
448
+ for (const extendBlock of extendBlocks) {
449
+ const targetId = extendBlock.before || extendBlock.after
450
+ const insertBefore = !!extendBlock.before
451
+ const targetIndex = result.findIndex(b => b.id === targetId)
452
+
453
+ if (targetIndex === -1) {
454
+ // Target not found - warn and fall back to weight-based positioning
455
+ if (this._debug) {
456
+ console.warn(
457
+ `[ZoneRegistry] Extend target "${targetId}" not found in zone "${zoneName}". ` +
458
+ `Adding block at its own weight.`
459
+ )
460
+ }
461
+ // Insert at position based on weight
462
+ const insertIndex = result.findIndex(b => b.weight > extendBlock.weight)
463
+ if (insertIndex === -1) {
464
+ result.push(extendBlock)
465
+ } else {
466
+ result.splice(insertIndex, 0, extendBlock)
467
+ }
468
+ } else {
469
+ // Insert before or after target
470
+ const insertIndex = insertBefore ? targetIndex : targetIndex + 1
471
+ result.splice(insertIndex, 0, extendBlock)
472
+ }
473
+ }
474
+
475
+ // Apply wrap operations
476
+ // Build wrapper chains: for each target, collect all wrappers sorted by weight
477
+ if (wrapBlocks.length > 0) {
478
+ // Group wraps by target ID
479
+ const wrapsByTarget = new Map()
480
+ for (const wrapBlock of wrapBlocks) {
481
+ if (!wrapsByTarget.has(wrapBlock.wraps)) {
482
+ wrapsByTarget.set(wrapBlock.wraps, [])
483
+ }
484
+ wrapsByTarget.get(wrapBlock.wraps).push(wrapBlock)
485
+ }
486
+
487
+ // Collect IDs of blocks in result for orphan detection
488
+ const resultBlockIds = new Set(result.map(b => b.id).filter(Boolean))
489
+
490
+ // Apply wrappers to blocks in result
491
+ for (let i = 0; i < result.length; i++) {
492
+ const block = result[i]
493
+ if (!block.id) continue
494
+
495
+ // Collect all wrappers for this block (direct and nested)
496
+ const allWrappers = this._collectWrapChain(block.id, wrapsByTarget)
497
+
498
+ if (allWrappers.length > 0) {
499
+ // Sort wrappers by weight (lower weight = outer wrapper)
500
+ allWrappers.sort((a, b) => a.weight - b.weight)
501
+
502
+ result[i] = {
503
+ ...block,
504
+ wrappers: allWrappers.map(w => ({
505
+ component: w.component,
506
+ props: w.props,
507
+ id: w.id
508
+ }))
509
+ }
510
+ }
511
+ }
512
+
513
+ // Warn about orphaned wrappers (wrappers targeting non-existent blocks)
514
+ if (this._debug) {
515
+ for (const [targetId, wrappers] of wrapsByTarget.entries()) {
516
+ // Check if target exists in result blocks or as a wrapper ID
517
+ const wrapperIds = new Set(wrapBlocks.map(w => w.id))
518
+ if (!resultBlockIds.has(targetId) && !wrapperIds.has(targetId)) {
519
+ for (const wrapper of wrappers) {
520
+ console.warn(
521
+ `[ZoneRegistry] Wrap target "${targetId}" not found in zone "${zoneName}". ` +
522
+ `Wrapper "${wrapper.id}" will be ignored.`
523
+ )
524
+ }
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ // Clean up internal operation fields for external consumers
531
+ const cleaned = result.map(({ component, weight, props, id, wrappers }) => ({
532
+ component,
533
+ weight,
534
+ props,
535
+ id,
536
+ ...(wrappers ? { wrappers } : {})
537
+ }))
538
+
539
+ // Cache the result
540
+ this._sortedCache.set(zoneName, cleaned)
541
+
542
+ return cleaned
543
+ }
544
+
545
+ /**
546
+ * Collect all wrappers for a block, including nested wrappers
547
+ *
548
+ * If WrapperA wraps MainContent and WrapperB wraps WrapperA,
549
+ * returns [WrapperA, WrapperB] (inner to outer before sorting)
550
+ *
551
+ * @param {string} targetId - Block ID being wrapped
552
+ * @param {Map<string, object[]>} wrapsByTarget - Map of target ID -> wrapper blocks
553
+ * @returns {object[]} - Array of wrapper block configs
554
+ * @private
555
+ */
556
+ _collectWrapChain(targetId, wrapsByTarget) {
557
+ const directWrappers = wrapsByTarget.get(targetId) || []
558
+
559
+ if (directWrappers.length === 0) {
560
+ return []
561
+ }
562
+
563
+ // Collect nested wrappers (wrappers of wrappers)
564
+ const allWrappers = [...directWrappers]
565
+
566
+ for (const wrapper of directWrappers) {
567
+ const nestedWrappers = this._collectWrapChain(wrapper.id, wrapsByTarget)
568
+ allWrappers.push(...nestedWrappers)
569
+ }
570
+
571
+ return allWrappers
572
+ }
573
+
574
+ /**
575
+ * Get zone default component
576
+ *
577
+ * @param {string} zoneName - Zone name
578
+ * @returns {import('vue').Component|null} - Default component or null
579
+ */
580
+ getDefault(zoneName) {
581
+ const zone = this._zones.get(zoneName)
582
+ return zone?.default ?? null
583
+ }
584
+
585
+ /**
586
+ * Check if a zone has any blocks registered
587
+ *
588
+ * @param {string} zoneName - Zone name
589
+ * @returns {boolean}
590
+ */
591
+ hasBlocks(zoneName) {
592
+ const zone = this._zones.get(zoneName)
593
+ return zone ? zone.blocks.length > 0 : false
594
+ }
595
+
596
+ /**
597
+ * Check if a zone is defined
598
+ *
599
+ * @param {string} zoneName - Zone name
600
+ * @returns {boolean}
601
+ */
602
+ hasZone(zoneName) {
603
+ return this._zones.has(zoneName)
604
+ }
605
+
606
+ /**
607
+ * List all defined zones with metadata
608
+ *
609
+ * Useful for debugging and introspection. Returns zone names with block counts.
610
+ *
611
+ * @returns {Array<{name: string, blockCount: number}>} - Array of zone info objects
612
+ */
613
+ listZones() {
614
+ const result = []
615
+ for (const [name, zone] of this._zones) {
616
+ result.push({
617
+ name,
618
+ blockCount: zone.blocks.length
619
+ })
620
+ }
621
+ return result
622
+ }
623
+
624
+ /**
625
+ * Get zone info for debugging
626
+ *
627
+ * @param {string} zoneName - Zone name
628
+ * @returns {object|null} - Zone info or null if not defined
629
+ */
630
+ getZoneInfo(zoneName) {
631
+ const zone = this._zones.get(zoneName)
632
+ if (!zone) return null
633
+
634
+ return {
635
+ name: zoneName,
636
+ hasDefault: zone.default !== null,
637
+ blockCount: zone.blocks.length,
638
+ blocks: zone.blocks.map(b => ({
639
+ id: b.id,
640
+ weight: b.weight,
641
+ hasProps: Object.keys(b.props).length > 0
642
+ }))
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Inspect a zone for debugging - detailed view with component info
648
+ *
649
+ * Returns zone details including blocks with component names,
650
+ * suitable for DevTools console inspection.
651
+ *
652
+ * @param {string} zoneName - Zone name
653
+ * @returns {object|null} - Detailed zone inspection or null if not defined
654
+ */
655
+ inspect(zoneName) {
656
+ const zone = this._zones.get(zoneName)
657
+ if (!zone) return null
658
+
659
+ // Get sorted blocks for accurate representation
660
+ const sortedBlocks = this.getBlocks(zoneName)
661
+
662
+ return {
663
+ name: zoneName,
664
+ blocks: sortedBlocks.map(b => ({
665
+ id: b.id,
666
+ weight: b.weight,
667
+ component: this._getComponentName(b.component),
668
+ ...(b.wrappers ? {
669
+ wrappers: b.wrappers.map(w => ({
670
+ id: w.id,
671
+ component: this._getComponentName(w.component)
672
+ }))
673
+ } : {})
674
+ })),
675
+ default: zone.default ? this._getComponentName(zone.default) : null
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Get a human-readable component name for debugging
681
+ *
682
+ * @param {import('vue').Component} component - Vue component
683
+ * @returns {string} - Component name or fallback
684
+ * @private
685
+ */
686
+ _getComponentName(component) {
687
+ if (!component) return '(none)'
688
+ if (typeof component === 'string') return component
689
+ return component.name || component.__name || '(anonymous)'
690
+ }
691
+
692
+ /**
693
+ * Remove all blocks from a zone
694
+ *
695
+ * @param {string} zoneName - Zone name
696
+ * @returns {this} - For chaining
697
+ */
698
+ clearZone(zoneName) {
699
+ const zone = this._zones.get(zoneName)
700
+ if (zone) {
701
+ const hadBlocks = zone.blocks.length > 0
702
+ zone.blocks = []
703
+ this._sortedCache.delete(zoneName)
704
+ this._wrapGraph.delete(zoneName)
705
+
706
+ // Trigger Vue reactivity update only if we removed blocks
707
+ if (hadBlocks) {
708
+ this._triggerUpdate()
709
+ }
710
+ }
711
+ return this
712
+ }
713
+
714
+ /**
715
+ * Remove a specific block by ID
716
+ *
717
+ * Also available as `unregisterBlock()` for semantic clarity in runtime scenarios.
718
+ *
719
+ * @param {string} zoneName - Zone name
720
+ * @param {string} blockId - Block ID to remove
721
+ * @returns {boolean} - True if block was found and removed
722
+ */
723
+ removeBlock(zoneName, blockId) {
724
+ const zone = this._zones.get(zoneName)
725
+ if (!zone) {
726
+ if (this._debug) {
727
+ console.warn(`[ZoneRegistry] Cannot remove block "${blockId}": zone "${zoneName}" not found`)
728
+ }
729
+ return false
730
+ }
731
+
732
+ const index = zone.blocks.findIndex(b => b.id === blockId)
733
+ if (index === -1) {
734
+ if (this._debug) {
735
+ console.warn(`[ZoneRegistry] Block "${blockId}" not found in zone "${zoneName}"`)
736
+ }
737
+ return false
738
+ }
739
+
740
+ const block = zone.blocks[index]
741
+ zone.blocks.splice(index, 1)
742
+ this._sortedCache.delete(zoneName)
743
+
744
+ // Remove from wrap graph if it was a wrap operation
745
+ if (block.operation === 'wrap') {
746
+ const zoneGraph = this._wrapGraph.get(zoneName)
747
+ if (zoneGraph) {
748
+ zoneGraph.delete(blockId)
749
+ }
750
+ }
751
+
752
+ // Trigger Vue reactivity update
753
+ this._triggerUpdate()
754
+
755
+ if (this._debug) {
756
+ console.debug(`[qdadm:zones] Unregistered block from zone: ${zoneName}, ${blockId}`)
757
+ }
758
+
759
+ return true
760
+ }
761
+
762
+ /**
763
+ * Unregister a block from a zone at runtime
764
+ *
765
+ * Alias for `removeBlock()` with semantic clarity for runtime block management.
766
+ * Use this when dynamically adding/removing blocks based on user state, feature flags, etc.
767
+ *
768
+ * @param {string} zoneName - Zone name
769
+ * @param {string} blockId - Block ID to unregister
770
+ * @returns {boolean} - True if block was found and removed
771
+ *
772
+ * @example
773
+ * // Register a block when component mounts
774
+ * onMounted(() => {
775
+ * registry.registerBlock('sidebar', { component: AdBanner, weight: 50, id: 'ad-banner' })
776
+ * })
777
+ *
778
+ * // Unregister when component unmounts
779
+ * onUnmounted(() => {
780
+ * registry.unregisterBlock('sidebar', 'ad-banner')
781
+ * })
782
+ */
783
+ unregisterBlock(zoneName, blockId) {
784
+ return this.removeBlock(zoneName, blockId)
785
+ }
786
+
787
+ /**
788
+ * Clear all zones and blocks
789
+ *
790
+ * Useful for testing.
791
+ *
792
+ * @returns {this} - For chaining
793
+ */
794
+ clear() {
795
+ const hadZones = this._zones.size > 0
796
+ this._zones.clear()
797
+ this._sortedCache.clear()
798
+ this._wrapGraph.clear()
799
+
800
+ // Trigger Vue reactivity update only if we had zones
801
+ if (hadZones) {
802
+ this._triggerUpdate()
803
+ }
804
+ return this
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Create a new ZoneRegistry instance
810
+ *
811
+ * @param {object} [options={}]
812
+ * @param {boolean} [options.debug=false] - Enable debug warnings
813
+ * @returns {ZoneRegistry}
814
+ */
815
+ export function createZoneRegistry(options = {}) {
816
+ const registry = new ZoneRegistry()
817
+ if (options.debug) {
818
+ registry.setDebug(true)
819
+ }
820
+ return registry
821
+ }