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.
- package/README.md +153 -1
- package/package.json +15 -2
- 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/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/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 +6 -6
- 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/useNavigation.js +38 -2
- 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 +9 -0
- package/src/kernel/Kernel.js +136 -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/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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityManager Decorator Pattern Utilities
|
|
3
|
+
*
|
|
4
|
+
* Decorators wrap an EntityManager to add cross-cutting concerns
|
|
5
|
+
* (auditing, caching, soft-delete) without modifying the base class.
|
|
6
|
+
*
|
|
7
|
+
* Key concepts:
|
|
8
|
+
* - Decorators receive a manager and return an enhanced manager
|
|
9
|
+
* - Decorators compose: first wraps closest to base, last wraps outermost
|
|
10
|
+
* - Each decorator intercepts CRUD operations, adds behavior, delegates to wrapped manager
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Single decorator
|
|
14
|
+
* const auditedBooks = createDecoratedManager(booksManager, [
|
|
15
|
+
* withAuditLog(console.log)
|
|
16
|
+
* ])
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // Stacked decorators (order matters: audit wraps cache wraps base)
|
|
20
|
+
* const enhancedBooks = createDecoratedManager(booksManager, [
|
|
21
|
+
* withCache({ ttl: 60000 }),
|
|
22
|
+
* withAuditLog(logger.info)
|
|
23
|
+
* ])
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply a chain of decorators to an EntityManager
|
|
28
|
+
*
|
|
29
|
+
* Decorators are applied in order: first decorator wraps the base manager,
|
|
30
|
+
* second wraps the first, etc. This means the last decorator in the array
|
|
31
|
+
* is the outermost layer (executed first on method calls).
|
|
32
|
+
*
|
|
33
|
+
* @param {EntityManager} manager - Base EntityManager to decorate
|
|
34
|
+
* @param {Function[]} decorators - Array of decorator functions
|
|
35
|
+
* @returns {EntityManager} - Decorated manager
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // Decorator function signature
|
|
39
|
+
* const myDecorator = (options) => (manager) => {
|
|
40
|
+
* return {
|
|
41
|
+
* ...manager,
|
|
42
|
+
* async create(data) {
|
|
43
|
+
* // Pre-processing
|
|
44
|
+
* const result = await manager.create(data)
|
|
45
|
+
* // Post-processing
|
|
46
|
+
* return result
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
*/
|
|
51
|
+
export function createDecoratedManager(manager, decorators = []) {
|
|
52
|
+
if (!manager) {
|
|
53
|
+
throw new Error('[createDecoratedManager] Manager is required')
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(decorators)) {
|
|
56
|
+
throw new Error('[createDecoratedManager] Decorators must be an array')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Apply decorators in sequence (first wraps base, last is outermost)
|
|
60
|
+
return decorators.reduce((decorated, decorator) => {
|
|
61
|
+
if (typeof decorator !== 'function') {
|
|
62
|
+
throw new Error('[createDecoratedManager] Each decorator must be a function')
|
|
63
|
+
}
|
|
64
|
+
return decorator(decorated)
|
|
65
|
+
}, manager)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Audit log decorator factory
|
|
70
|
+
*
|
|
71
|
+
* Logs CRUD operations with timestamps. Useful for tracking changes,
|
|
72
|
+
* debugging, or audit trails.
|
|
73
|
+
*
|
|
74
|
+
* @param {Function} logger - Logging function (receives action, details)
|
|
75
|
+
* @param {object} [options] - Options
|
|
76
|
+
* @param {boolean} [options.includeData=false] - Include record data in logs
|
|
77
|
+
* @returns {Function} Decorator function
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const auditedManager = createDecoratedManager(manager, [
|
|
81
|
+
* withAuditLog(console.log)
|
|
82
|
+
* ])
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* // With custom logger
|
|
86
|
+
* const auditedManager = createDecoratedManager(manager, [
|
|
87
|
+
* withAuditLog((action, details) => {
|
|
88
|
+
* auditService.log({ action, ...details, timestamp: new Date() })
|
|
89
|
+
* }, { includeData: true })
|
|
90
|
+
* ])
|
|
91
|
+
*/
|
|
92
|
+
export function withAuditLog(logger, options = {}) {
|
|
93
|
+
if (typeof logger !== 'function') {
|
|
94
|
+
throw new Error('[withAuditLog] Logger must be a function')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { includeData = false } = options
|
|
98
|
+
|
|
99
|
+
return (manager) => {
|
|
100
|
+
const entityName = manager.name
|
|
101
|
+
|
|
102
|
+
const logAction = (action, details) => {
|
|
103
|
+
logger(action, {
|
|
104
|
+
entity: entityName,
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
...details
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
// Preserve all manager properties and methods
|
|
112
|
+
...manager,
|
|
113
|
+
// Ensure name is accessible (might be a getter)
|
|
114
|
+
get name() { return manager.name },
|
|
115
|
+
|
|
116
|
+
async create(data) {
|
|
117
|
+
const result = await manager.create(data)
|
|
118
|
+
const logDetails = { id: result?.[manager.idField] }
|
|
119
|
+
if (includeData) logDetails.data = data
|
|
120
|
+
logAction('create', logDetails)
|
|
121
|
+
return result
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async update(id, data) {
|
|
125
|
+
const result = await manager.update(id, data)
|
|
126
|
+
const logDetails = { id }
|
|
127
|
+
if (includeData) logDetails.data = data
|
|
128
|
+
logAction('update', logDetails)
|
|
129
|
+
return result
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async patch(id, data) {
|
|
133
|
+
const result = await manager.patch(id, data)
|
|
134
|
+
const logDetails = { id }
|
|
135
|
+
if (includeData) logDetails.data = data
|
|
136
|
+
logAction('patch', logDetails)
|
|
137
|
+
return result
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async delete(id) {
|
|
141
|
+
await manager.delete(id)
|
|
142
|
+
logAction('delete', { id })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Soft delete decorator factory
|
|
150
|
+
*
|
|
151
|
+
* Converts delete() to update with deleted_at timestamp.
|
|
152
|
+
* Useful for audit trails, undo functionality, or legal compliance.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} [options] - Options
|
|
155
|
+
* @param {string} [options.field='deleted_at'] - Field name for deletion timestamp
|
|
156
|
+
* @param {Function} [options.timestamp] - Custom timestamp function (default: ISO string)
|
|
157
|
+
* @returns {Function} Decorator function
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* const softDeleteManager = createDecoratedManager(manager, [
|
|
161
|
+
* withSoftDelete()
|
|
162
|
+
* ])
|
|
163
|
+
*
|
|
164
|
+
* // Instead of deleting, sets deleted_at
|
|
165
|
+
* await softDeleteManager.delete(1)
|
|
166
|
+
* // Record: { id: 1, ..., deleted_at: '2024-01-01T00:00:00.000Z' }
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* // Custom field and timestamp
|
|
170
|
+
* const softDeleteManager = createDecoratedManager(manager, [
|
|
171
|
+
* withSoftDelete({
|
|
172
|
+
* field: 'removed_at',
|
|
173
|
+
* timestamp: () => Date.now()
|
|
174
|
+
* })
|
|
175
|
+
* ])
|
|
176
|
+
*/
|
|
177
|
+
export function withSoftDelete(options = {}) {
|
|
178
|
+
const {
|
|
179
|
+
field = 'deleted_at',
|
|
180
|
+
timestamp = () => new Date().toISOString()
|
|
181
|
+
} = options
|
|
182
|
+
|
|
183
|
+
return (manager) => ({
|
|
184
|
+
...manager,
|
|
185
|
+
get name() { return manager.name },
|
|
186
|
+
|
|
187
|
+
async delete(id) {
|
|
188
|
+
// Convert delete to update with soft-delete field
|
|
189
|
+
return manager.patch(id, { [field]: timestamp() })
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Timestamp decorator factory
|
|
196
|
+
*
|
|
197
|
+
* Automatically adds created_at and updated_at timestamps to records.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} [options] - Options
|
|
200
|
+
* @param {string} [options.createdAtField='created_at'] - Field for creation timestamp
|
|
201
|
+
* @param {string} [options.updatedAtField='updated_at'] - Field for update timestamp
|
|
202
|
+
* @param {Function} [options.timestamp] - Custom timestamp function
|
|
203
|
+
* @returns {Function} Decorator function
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* const timestampedManager = createDecoratedManager(manager, [
|
|
207
|
+
* withTimestamps()
|
|
208
|
+
* ])
|
|
209
|
+
*
|
|
210
|
+
* await timestampedManager.create({ title: 'Test' })
|
|
211
|
+
* // Record: { title: 'Test', created_at: '...', updated_at: '...' }
|
|
212
|
+
*
|
|
213
|
+
* await timestampedManager.update(1, { title: 'Updated' })
|
|
214
|
+
* // Record: { title: 'Updated', updated_at: '...' }
|
|
215
|
+
*/
|
|
216
|
+
export function withTimestamps(options = {}) {
|
|
217
|
+
const {
|
|
218
|
+
createdAtField = 'created_at',
|
|
219
|
+
updatedAtField = 'updated_at',
|
|
220
|
+
timestamp = () => new Date().toISOString()
|
|
221
|
+
} = options
|
|
222
|
+
|
|
223
|
+
return (manager) => ({
|
|
224
|
+
...manager,
|
|
225
|
+
get name() { return manager.name },
|
|
226
|
+
|
|
227
|
+
async create(data) {
|
|
228
|
+
const now = timestamp()
|
|
229
|
+
return manager.create({
|
|
230
|
+
...data,
|
|
231
|
+
[createdAtField]: now,
|
|
232
|
+
[updatedAtField]: now
|
|
233
|
+
})
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async update(id, data) {
|
|
237
|
+
return manager.update(id, {
|
|
238
|
+
...data,
|
|
239
|
+
[updatedAtField]: timestamp()
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async patch(id, data) {
|
|
244
|
+
return manager.patch(id, {
|
|
245
|
+
...data,
|
|
246
|
+
[updatedAtField]: timestamp()
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validation decorator factory
|
|
254
|
+
*
|
|
255
|
+
* Validates data before create/update operations.
|
|
256
|
+
* Throws ValidationError if validation fails.
|
|
257
|
+
*
|
|
258
|
+
* @param {Function} validator - Validation function (data, context) => errors | null
|
|
259
|
+
* @param {object} [options] - Options
|
|
260
|
+
* @param {boolean} [options.onUpdate=true] - Validate on update
|
|
261
|
+
* @param {boolean} [options.onPatch=true] - Validate on patch
|
|
262
|
+
* @returns {Function} Decorator function
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* const validatedManager = createDecoratedManager(manager, [
|
|
266
|
+
* withValidation((data) => {
|
|
267
|
+
* const errors = {}
|
|
268
|
+
* if (!data.title) errors.title = 'Title is required'
|
|
269
|
+
* if (data.price < 0) errors.price = 'Price must be positive'
|
|
270
|
+
* return Object.keys(errors).length ? errors : null
|
|
271
|
+
* })
|
|
272
|
+
* ])
|
|
273
|
+
*
|
|
274
|
+
* await validatedManager.create({ price: -5 })
|
|
275
|
+
* // Throws: ValidationError { errors: { title: '...', price: '...' } }
|
|
276
|
+
*/
|
|
277
|
+
export function withValidation(validator, options = {}) {
|
|
278
|
+
if (typeof validator !== 'function') {
|
|
279
|
+
throw new Error('[withValidation] Validator must be a function')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const {
|
|
283
|
+
onUpdate = true,
|
|
284
|
+
onPatch = true
|
|
285
|
+
} = options
|
|
286
|
+
|
|
287
|
+
return (manager) => {
|
|
288
|
+
const validate = (data, context) => {
|
|
289
|
+
const errors = validator(data, context)
|
|
290
|
+
if (errors && Object.keys(errors).length > 0) {
|
|
291
|
+
const error = new Error('Validation failed')
|
|
292
|
+
error.name = 'ValidationError'
|
|
293
|
+
error.errors = errors
|
|
294
|
+
throw error
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
...manager,
|
|
300
|
+
get name() { return manager.name },
|
|
301
|
+
|
|
302
|
+
async create(data) {
|
|
303
|
+
validate(data, { action: 'create', manager })
|
|
304
|
+
return manager.create(data)
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async update(id, data) {
|
|
308
|
+
if (onUpdate) {
|
|
309
|
+
validate(data, { action: 'update', id, manager })
|
|
310
|
+
}
|
|
311
|
+
return manager.update(id, data)
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async patch(id, data) {
|
|
315
|
+
if (onPatch) {
|
|
316
|
+
validate(data, { action: 'patch', id, manager })
|
|
317
|
+
}
|
|
318
|
+
return manager.patch(id, data)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extendModule - Module extension helper for qdadm
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean API for one module to extend another module's
|
|
5
|
+
* configuration and behavior through the hook system.
|
|
6
|
+
*
|
|
7
|
+
* Extension types:
|
|
8
|
+
* - columns: adds columns via `{target}:list:alter` hook
|
|
9
|
+
* - fields: adds fields via `{target}:form:alter` hook
|
|
10
|
+
* - filters: adds filters via `{target}:filter:alter` hook
|
|
11
|
+
* - blocks: injects blocks via zone registry
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Simple config object approach
|
|
15
|
+
* const cleanup = extendModule('books', {
|
|
16
|
+
* columns: [
|
|
17
|
+
* { field: 'rating', header: 'Rating', width: 100 }
|
|
18
|
+
* ],
|
|
19
|
+
* fields: [
|
|
20
|
+
* { name: 'rating', type: 'number', label: 'Rating' }
|
|
21
|
+
* ],
|
|
22
|
+
* blocks: {
|
|
23
|
+
* 'books:detail:sidebar': [
|
|
24
|
+
* { component: RatingWidget, weight: 20 }
|
|
25
|
+
* ]
|
|
26
|
+
* }
|
|
27
|
+
* }, { hooks, zones })
|
|
28
|
+
*
|
|
29
|
+
* // Later: cleanup() // Removes all registered extensions
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // Fluent API approach
|
|
33
|
+
* const cleanup = extendModule('books')
|
|
34
|
+
* .addColumn({ field: 'rating', header: 'Rating' })
|
|
35
|
+
* .addField({ name: 'rating', type: 'number' })
|
|
36
|
+
* .addBlock('books:detail:sidebar', { component: RatingWidget })
|
|
37
|
+
* .register({ hooks, zones })
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default priority for extension hooks
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_PRIORITY = 50
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extension builder for fluent API
|
|
47
|
+
*
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
class ExtensionBuilder {
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} target - Target module name to extend
|
|
53
|
+
*/
|
|
54
|
+
constructor(target) {
|
|
55
|
+
if (!target || typeof target !== 'string') {
|
|
56
|
+
throw new Error('[extendModule] Target module name must be a non-empty string')
|
|
57
|
+
}
|
|
58
|
+
this._target = target
|
|
59
|
+
this._columns = []
|
|
60
|
+
this._fields = []
|
|
61
|
+
this._filters = []
|
|
62
|
+
this._blocks = new Map()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Add a column to the target module's list view
|
|
67
|
+
*
|
|
68
|
+
* @param {object} column - Column configuration
|
|
69
|
+
* @param {string} column.field - Field name
|
|
70
|
+
* @param {string} [column.header] - Column header label
|
|
71
|
+
* @param {number} [column.width] - Column width
|
|
72
|
+
* @param {Function} [column.body] - Custom body template function
|
|
73
|
+
* @returns {this} For chaining
|
|
74
|
+
*/
|
|
75
|
+
addColumn(column) {
|
|
76
|
+
if (!column || !column.field) {
|
|
77
|
+
throw new Error('[extendModule] Column must have a field property')
|
|
78
|
+
}
|
|
79
|
+
this._columns.push(column)
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add multiple columns to the target module's list view
|
|
85
|
+
*
|
|
86
|
+
* @param {object[]} columns - Array of column configurations
|
|
87
|
+
* @returns {this} For chaining
|
|
88
|
+
*/
|
|
89
|
+
addColumns(columns) {
|
|
90
|
+
if (!Array.isArray(columns)) {
|
|
91
|
+
throw new Error('[extendModule] addColumns expects an array')
|
|
92
|
+
}
|
|
93
|
+
for (const column of columns) {
|
|
94
|
+
this.addColumn(column)
|
|
95
|
+
}
|
|
96
|
+
return this
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Add a field to the target module's form view
|
|
101
|
+
*
|
|
102
|
+
* @param {object} field - Field configuration
|
|
103
|
+
* @param {string} field.name - Field name
|
|
104
|
+
* @param {string} [field.type='text'] - Field type
|
|
105
|
+
* @param {string} [field.label] - Field label
|
|
106
|
+
* @returns {this} For chaining
|
|
107
|
+
*/
|
|
108
|
+
addField(field) {
|
|
109
|
+
if (!field || !field.name) {
|
|
110
|
+
throw new Error('[extendModule] Field must have a name property')
|
|
111
|
+
}
|
|
112
|
+
this._fields.push(field)
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Add multiple fields to the target module's form view
|
|
118
|
+
*
|
|
119
|
+
* @param {object[]} fields - Array of field configurations
|
|
120
|
+
* @returns {this} For chaining
|
|
121
|
+
*/
|
|
122
|
+
addFields(fields) {
|
|
123
|
+
if (!Array.isArray(fields)) {
|
|
124
|
+
throw new Error('[extendModule] addFields expects an array')
|
|
125
|
+
}
|
|
126
|
+
for (const field of fields) {
|
|
127
|
+
this.addField(field)
|
|
128
|
+
}
|
|
129
|
+
return this
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add a filter to the target module's filter bar
|
|
134
|
+
*
|
|
135
|
+
* @param {object} filter - Filter configuration
|
|
136
|
+
* @param {string} filter.name - Filter name
|
|
137
|
+
* @param {string} [filter.type='text'] - Filter type
|
|
138
|
+
* @param {string} [filter.label] - Filter label
|
|
139
|
+
* @returns {this} For chaining
|
|
140
|
+
*/
|
|
141
|
+
addFilter(filter) {
|
|
142
|
+
if (!filter || !filter.name) {
|
|
143
|
+
throw new Error('[extendModule] Filter must have a name property')
|
|
144
|
+
}
|
|
145
|
+
this._filters.push(filter)
|
|
146
|
+
return this
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Add multiple filters to the target module's filter bar
|
|
151
|
+
*
|
|
152
|
+
* @param {object[]} filters - Array of filter configurations
|
|
153
|
+
* @returns {this} For chaining
|
|
154
|
+
*/
|
|
155
|
+
addFilters(filters) {
|
|
156
|
+
if (!Array.isArray(filters)) {
|
|
157
|
+
throw new Error('[extendModule] addFilters expects an array')
|
|
158
|
+
}
|
|
159
|
+
for (const filter of filters) {
|
|
160
|
+
this.addFilter(filter)
|
|
161
|
+
}
|
|
162
|
+
return this
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Add a block to a zone in the target module
|
|
167
|
+
*
|
|
168
|
+
* @param {string} zoneName - Zone name (e.g., 'books:detail:sidebar')
|
|
169
|
+
* @param {object} block - Block configuration
|
|
170
|
+
* @param {import('vue').Component} block.component - Vue component
|
|
171
|
+
* @param {number} [block.weight=50] - Block weight for ordering
|
|
172
|
+
* @param {object} [block.props={}] - Props to pass to component
|
|
173
|
+
* @param {string} [block.id] - Unique block ID
|
|
174
|
+
* @returns {this} For chaining
|
|
175
|
+
*/
|
|
176
|
+
addBlock(zoneName, block) {
|
|
177
|
+
if (!zoneName || typeof zoneName !== 'string') {
|
|
178
|
+
throw new Error('[extendModule] Zone name must be a non-empty string')
|
|
179
|
+
}
|
|
180
|
+
if (!block || !block.component) {
|
|
181
|
+
throw new Error('[extendModule] Block must have a component property')
|
|
182
|
+
}
|
|
183
|
+
if (!this._blocks.has(zoneName)) {
|
|
184
|
+
this._blocks.set(zoneName, [])
|
|
185
|
+
}
|
|
186
|
+
this._blocks.get(zoneName).push(block)
|
|
187
|
+
return this
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add multiple blocks to a zone
|
|
192
|
+
*
|
|
193
|
+
* @param {string} zoneName - Zone name
|
|
194
|
+
* @param {object[]} blocks - Array of block configurations
|
|
195
|
+
* @returns {this} For chaining
|
|
196
|
+
*/
|
|
197
|
+
addBlocks(zoneName, blocks) {
|
|
198
|
+
if (!Array.isArray(blocks)) {
|
|
199
|
+
throw new Error('[extendModule] addBlocks expects an array')
|
|
200
|
+
}
|
|
201
|
+
for (const block of blocks) {
|
|
202
|
+
this.addBlock(zoneName, block)
|
|
203
|
+
}
|
|
204
|
+
return this
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Register all extensions with the kernel
|
|
209
|
+
*
|
|
210
|
+
* @param {object} context - Registration context
|
|
211
|
+
* @param {object} context.hooks - HookRegistry instance
|
|
212
|
+
* @param {object} [context.zones] - ZoneRegistry instance (required for blocks)
|
|
213
|
+
* @param {number} [context.priority=50] - Hook priority
|
|
214
|
+
* @returns {Function} Cleanup function to remove all extensions
|
|
215
|
+
*/
|
|
216
|
+
register(context) {
|
|
217
|
+
if (!context || !context.hooks) {
|
|
218
|
+
throw new Error('[extendModule] register() requires { hooks } context')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { hooks, zones, priority = DEFAULT_PRIORITY } = context
|
|
222
|
+
const cleanupFns = []
|
|
223
|
+
|
|
224
|
+
// Register column alter hook
|
|
225
|
+
if (this._columns.length > 0) {
|
|
226
|
+
const hookName = `${this._target}:list:alter`
|
|
227
|
+
const columns = [...this._columns]
|
|
228
|
+
const unbind = hooks.register(hookName, (config) => {
|
|
229
|
+
if (!config.columns) {
|
|
230
|
+
config.columns = []
|
|
231
|
+
}
|
|
232
|
+
config.columns.push(...columns)
|
|
233
|
+
return config
|
|
234
|
+
}, { priority })
|
|
235
|
+
cleanupFns.push(unbind)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Register field alter hook
|
|
239
|
+
if (this._fields.length > 0) {
|
|
240
|
+
const hookName = `${this._target}:form:alter`
|
|
241
|
+
const fields = [...this._fields]
|
|
242
|
+
const unbind = hooks.register(hookName, (config) => {
|
|
243
|
+
if (!config.fields) {
|
|
244
|
+
config.fields = []
|
|
245
|
+
}
|
|
246
|
+
config.fields.push(...fields)
|
|
247
|
+
return config
|
|
248
|
+
}, { priority })
|
|
249
|
+
cleanupFns.push(unbind)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Register filter alter hook
|
|
253
|
+
if (this._filters.length > 0) {
|
|
254
|
+
const hookName = `${this._target}:filter:alter`
|
|
255
|
+
const filters = [...this._filters]
|
|
256
|
+
const unbind = hooks.register(hookName, (config) => {
|
|
257
|
+
if (!config.filters) {
|
|
258
|
+
config.filters = []
|
|
259
|
+
}
|
|
260
|
+
config.filters.push(...filters)
|
|
261
|
+
return config
|
|
262
|
+
}, { priority })
|
|
263
|
+
cleanupFns.push(unbind)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Register zone blocks
|
|
267
|
+
if (this._blocks.size > 0) {
|
|
268
|
+
if (!zones) {
|
|
269
|
+
throw new Error('[extendModule] register() requires { zones } context when blocks are defined')
|
|
270
|
+
}
|
|
271
|
+
for (const [zoneName, blocks] of this._blocks) {
|
|
272
|
+
for (const block of blocks) {
|
|
273
|
+
zones.registerBlock(zoneName, block)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Zone blocks cleanup: store zone/id pairs for removal
|
|
277
|
+
const blockIds = []
|
|
278
|
+
for (const [zoneName, blocks] of this._blocks) {
|
|
279
|
+
for (const block of blocks) {
|
|
280
|
+
if (block.id) {
|
|
281
|
+
blockIds.push({ zoneName, id: block.id })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (blockIds.length > 0) {
|
|
286
|
+
cleanupFns.push(() => {
|
|
287
|
+
for (const { zoneName, id } of blockIds) {
|
|
288
|
+
zones.removeBlock(zoneName, id)
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Return cleanup function
|
|
295
|
+
return () => {
|
|
296
|
+
for (const cleanup of cleanupFns) {
|
|
297
|
+
cleanup()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get collected extensions as a config object
|
|
304
|
+
*
|
|
305
|
+
* Useful for debugging or serialization.
|
|
306
|
+
*
|
|
307
|
+
* @returns {object} Extensions config
|
|
308
|
+
*/
|
|
309
|
+
toConfig() {
|
|
310
|
+
const config = {
|
|
311
|
+
target: this._target
|
|
312
|
+
}
|
|
313
|
+
if (this._columns.length > 0) {
|
|
314
|
+
config.columns = [...this._columns]
|
|
315
|
+
}
|
|
316
|
+
if (this._fields.length > 0) {
|
|
317
|
+
config.fields = [...this._fields]
|
|
318
|
+
}
|
|
319
|
+
if (this._filters.length > 0) {
|
|
320
|
+
config.filters = [...this._filters]
|
|
321
|
+
}
|
|
322
|
+
if (this._blocks.size > 0) {
|
|
323
|
+
config.blocks = {}
|
|
324
|
+
for (const [zoneName, blocks] of this._blocks) {
|
|
325
|
+
config.blocks[zoneName] = [...blocks]
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return config
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create an extension for a target module
|
|
334
|
+
*
|
|
335
|
+
* Can be used in two ways:
|
|
336
|
+
* 1. Fluent API: extendModule('target').addColumn(...).register({ hooks })
|
|
337
|
+
* 2. Config object: extendModule('target', { columns: [...] }, { hooks })
|
|
338
|
+
*
|
|
339
|
+
* @param {string} target - Target module name to extend
|
|
340
|
+
* @param {object} [extensions] - Optional extensions config object
|
|
341
|
+
* @param {object[]} [extensions.columns] - Columns to add to list view
|
|
342
|
+
* @param {object[]} [extensions.fields] - Fields to add to form view
|
|
343
|
+
* @param {object[]} [extensions.filters] - Filters to add to filter bar
|
|
344
|
+
* @param {Object.<string, object[]>} [extensions.blocks] - Blocks to add to zones
|
|
345
|
+
* @param {object} [context] - Registration context (required if extensions provided)
|
|
346
|
+
* @param {object} context.hooks - HookRegistry instance
|
|
347
|
+
* @param {object} [context.zones] - ZoneRegistry instance
|
|
348
|
+
* @param {number} [context.priority] - Hook priority
|
|
349
|
+
* @returns {ExtensionBuilder|Function} Builder for fluent API, or cleanup function if extensions provided
|
|
350
|
+
*/
|
|
351
|
+
export function extendModule(target, extensions, context) {
|
|
352
|
+
const builder = new ExtensionBuilder(target)
|
|
353
|
+
|
|
354
|
+
// If no extensions provided, return builder for fluent API
|
|
355
|
+
if (!extensions) {
|
|
356
|
+
return builder
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Config object approach: apply extensions and register immediately
|
|
360
|
+
if (extensions.columns) {
|
|
361
|
+
builder.addColumns(extensions.columns)
|
|
362
|
+
}
|
|
363
|
+
if (extensions.fields) {
|
|
364
|
+
builder.addFields(extensions.fields)
|
|
365
|
+
}
|
|
366
|
+
if (extensions.filters) {
|
|
367
|
+
builder.addFilters(extensions.filters)
|
|
368
|
+
}
|
|
369
|
+
if (extensions.blocks) {
|
|
370
|
+
for (const [zoneName, blocks] of Object.entries(extensions.blocks)) {
|
|
371
|
+
builder.addBlocks(zoneName, blocks)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Context required for config object approach
|
|
376
|
+
if (!context) {
|
|
377
|
+
throw new Error('[extendModule] Context { hooks } is required when extensions are provided')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return builder.register(context)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* ExtensionBuilder class export for instanceof checks
|
|
385
|
+
*/
|
|
386
|
+
export { ExtensionBuilder }
|