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.
- package/README.md +153 -1
- package/package.json +15 -2
- package/src/components/BoolCell.vue +11 -6
- package/src/components/forms/FormField.vue +64 -6
- package/src/components/forms/FormPage.vue +276 -0
- package/src/components/index.js +11 -0
- package/src/components/layout/AppLayout.vue +18 -9
- package/src/components/layout/BaseLayout.vue +183 -0
- package/src/components/layout/DashboardLayout.vue +100 -0
- package/src/components/layout/FormLayout.vue +261 -0
- package/src/components/layout/ListLayout.vue +334 -0
- package/src/components/layout/PageHeader.vue +6 -9
- package/src/components/layout/PageNav.vue +15 -0
- package/src/components/layout/Zone.vue +165 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
- package/src/components/layout/defaults/DefaultFooter.vue +56 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
- package/src/components/layout/defaults/DefaultHeader.vue +69 -0
- package/src/components/layout/defaults/DefaultMenu.vue +197 -0
- package/src/components/layout/defaults/DefaultPagination.vue +79 -0
- package/src/components/layout/defaults/DefaultTable.vue +130 -0
- package/src/components/layout/defaults/DefaultToaster.vue +16 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
- package/src/components/layout/defaults/index.js +17 -0
- package/src/composables/index.js +8 -6
- package/src/composables/useBreadcrumb.js +9 -5
- package/src/composables/useForm.js +135 -0
- package/src/composables/useFormPageBuilder.js +1154 -0
- package/src/composables/useHooks.js +53 -0
- package/src/composables/useLayoutResolver.js +260 -0
- package/src/composables/useListPageBuilder.js +336 -52
- package/src/composables/useNavContext.js +372 -0
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/usePageTitle.js +59 -0
- package/src/composables/useSignals.js +49 -0
- package/src/composables/useZoneRegistry.js +162 -0
- package/src/core/bundles.js +406 -0
- package/src/core/decorator.js +322 -0
- package/src/core/extension.js +386 -0
- package/src/core/index.js +28 -0
- package/src/entity/EntityManager.js +314 -16
- package/src/entity/auth/AuthAdapter.js +125 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/index.js +11 -0
- package/src/entity/index.js +3 -0
- package/src/entity/storage/MockApiStorage.js +349 -0
- package/src/entity/storage/SdkStorage.js +478 -0
- package/src/entity/storage/index.js +2 -0
- package/src/hooks/HookRegistry.js +411 -0
- package/src/hooks/index.js +12 -0
- package/src/index.js +12 -0
- package/src/kernel/Kernel.js +141 -4
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +124 -6
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/plugin.js +5 -0
- package/src/zones/ZoneRegistry.js +821 -0
- package/src/zones/index.js +16 -0
- package/src/zones/zones.js +189 -0
- package/src/composables/useEntityTitle.js +0 -121
- package/src/composables/useManager.js +0 -20
- package/src/composables/usePageBuilder.js +0 -334
- package/src/composables/useStatus.js +0 -146
- package/src/composables/useSubEditor.js +0 -165
- 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
|
+
}
|