qdadm 0.13.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/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- package/src/utils/transformers.js +105 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityManager - Base class for entity CRUD operations
|
|
3
|
+
*
|
|
4
|
+
* An EntityManager can either:
|
|
5
|
+
* 1. BE its own storage (implement methods directly)
|
|
6
|
+
* 2. DELEGATE to a storage adapter
|
|
7
|
+
*
|
|
8
|
+
* This class provides the interface and optional delegation pattern.
|
|
9
|
+
*
|
|
10
|
+
* Usage (with delegation):
|
|
11
|
+
* ```js
|
|
12
|
+
* const users = new EntityManager({
|
|
13
|
+
* name: 'users',
|
|
14
|
+
* storage: new ApiStorage({ endpoint: '/users', client: axios }),
|
|
15
|
+
* // Metadata for UI
|
|
16
|
+
* label: 'User',
|
|
17
|
+
* labelPlural: 'Users',
|
|
18
|
+
* routePrefix: 'user',
|
|
19
|
+
* // Fields schema
|
|
20
|
+
* fields: {
|
|
21
|
+
* username: { type: 'text', label: 'Username', required: true },
|
|
22
|
+
* email: { type: 'email', label: 'Email', required: true },
|
|
23
|
+
* role: { type: 'select', label: 'Role', options: [...] }
|
|
24
|
+
* },
|
|
25
|
+
* children: {
|
|
26
|
+
* roles: { entity: 'roles' },
|
|
27
|
+
* sessions: { entity: 'sessions', endpoint: ':id/sessions' }
|
|
28
|
+
* }
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Usage (direct implementation):
|
|
33
|
+
* ```js
|
|
34
|
+
* class UsersManager extends EntityManager {
|
|
35
|
+
* async list(params) { ... }
|
|
36
|
+
* async get(id) { ... }
|
|
37
|
+
* // etc.
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class EntityManager {
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
const {
|
|
44
|
+
name,
|
|
45
|
+
storage = null,
|
|
46
|
+
idField = 'id',
|
|
47
|
+
// Metadata for UI
|
|
48
|
+
label = null, // Singular label: 'User'
|
|
49
|
+
labelPlural = null, // Plural label: 'Users'
|
|
50
|
+
routePrefix = null, // Route prefix: 'user'
|
|
51
|
+
labelField = 'name', // Field to use as display label (breadcrumb, delete confirm, etc.)
|
|
52
|
+
// Fields schema
|
|
53
|
+
fields = {}, // { fieldName: { type, label, required, default, options, ... } }
|
|
54
|
+
// List behavior
|
|
55
|
+
localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
|
|
56
|
+
// Relations
|
|
57
|
+
children = {}, // { roles: { entity: 'roles', endpoint?: ':id/roles' } }
|
|
58
|
+
parent = null, // { entity: 'users', foreignKey: 'user_id' }
|
|
59
|
+
relations = {} // { groups: { entity: 'groups', through: 'user_groups' } }
|
|
60
|
+
} = options
|
|
61
|
+
|
|
62
|
+
this.name = name
|
|
63
|
+
this.storage = storage
|
|
64
|
+
this.idField = idField
|
|
65
|
+
this._labelField = labelField // Can be string or function
|
|
66
|
+
|
|
67
|
+
// Metadata (with smart defaults)
|
|
68
|
+
this._label = label
|
|
69
|
+
this._labelPlural = labelPlural
|
|
70
|
+
this._routePrefix = routePrefix
|
|
71
|
+
this._fields = fields
|
|
72
|
+
|
|
73
|
+
// List behavior
|
|
74
|
+
this.localFilterThreshold = localFilterThreshold
|
|
75
|
+
|
|
76
|
+
// Relations
|
|
77
|
+
this._children = children
|
|
78
|
+
this._parent = parent
|
|
79
|
+
this._relations = relations
|
|
80
|
+
this._orchestrator = null // Set when registered
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============ METADATA ACCESSORS ============
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get entity label (singular)
|
|
87
|
+
* Default: capitalize name (e.g., 'users' → 'User')
|
|
88
|
+
*/
|
|
89
|
+
get label() {
|
|
90
|
+
if (this._label) return this._label
|
|
91
|
+
if (!this.name) return 'Item'
|
|
92
|
+
// users → User, book → Book
|
|
93
|
+
const singular = this.name.endsWith('s') ? this.name.slice(0, -1) : this.name
|
|
94
|
+
return singular.charAt(0).toUpperCase() + singular.slice(1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get entity label (plural)
|
|
99
|
+
* Default: capitalize name or add 's' (e.g., 'user' → 'Users')
|
|
100
|
+
*/
|
|
101
|
+
get labelPlural() {
|
|
102
|
+
if (this._labelPlural) return this._labelPlural
|
|
103
|
+
if (!this.name) return 'Items'
|
|
104
|
+
// users → Users, book → Books
|
|
105
|
+
const plural = this.name.endsWith('s') ? this.name : this.name + 's'
|
|
106
|
+
return plural.charAt(0).toUpperCase() + plural.slice(1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get route prefix for this entity
|
|
111
|
+
* Default: singular form of name (e.g., 'books' → 'book')
|
|
112
|
+
*/
|
|
113
|
+
get routePrefix() {
|
|
114
|
+
if (this._routePrefix) return this._routePrefix
|
|
115
|
+
if (!this.name) return 'item'
|
|
116
|
+
return this.name.endsWith('s') ? this.name.slice(0, -1) : this.name
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get labelField config (string or function)
|
|
121
|
+
* Used by components to determine how to display entity labels
|
|
122
|
+
*/
|
|
123
|
+
get labelField() {
|
|
124
|
+
return this._labelField
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get display label for an entity
|
|
129
|
+
* Handles both string field name and callback function
|
|
130
|
+
* @param {object} entity - The entity object
|
|
131
|
+
* @returns {string|null} - The display label
|
|
132
|
+
*/
|
|
133
|
+
getEntityLabel(entity) {
|
|
134
|
+
if (!entity) return null
|
|
135
|
+
if (typeof this._labelField === 'function') {
|
|
136
|
+
return this._labelField(entity)
|
|
137
|
+
}
|
|
138
|
+
return entity[this._labelField] || null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============ PERMISSIONS ============
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if user can read entities
|
|
145
|
+
* Override in subclass or provide via options to implement custom logic
|
|
146
|
+
*
|
|
147
|
+
* @param {object} [entity] - Optional: specific entity to check
|
|
148
|
+
* @returns {boolean} - true if user can read
|
|
149
|
+
*
|
|
150
|
+
* Without entity: general read permission (e.g., can see the list)
|
|
151
|
+
* With entity: specific read permission (e.g., can see this item)
|
|
152
|
+
*/
|
|
153
|
+
canRead(entity = null) {
|
|
154
|
+
// Default: allow all reads
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if user can create new entities
|
|
160
|
+
* Override in subclass to implement custom logic
|
|
161
|
+
*
|
|
162
|
+
* @returns {boolean} - true if user can create
|
|
163
|
+
*/
|
|
164
|
+
canCreate() {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if user can update entities
|
|
170
|
+
* Override in subclass to implement custom logic
|
|
171
|
+
*
|
|
172
|
+
* @param {object} [entity] - Optional: specific entity to check
|
|
173
|
+
* @returns {boolean} - true if user can update
|
|
174
|
+
*
|
|
175
|
+
* Without entity: general update permission
|
|
176
|
+
* With entity: specific update permission (e.g., can edit this item)
|
|
177
|
+
*/
|
|
178
|
+
canUpdate(entity = null) {
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if user can delete entities
|
|
184
|
+
* Override in subclass or provide via options to implement custom logic
|
|
185
|
+
*
|
|
186
|
+
* @param {object} [entity] - Optional: specific entity to check
|
|
187
|
+
* @returns {boolean} - true if user can delete
|
|
188
|
+
*/
|
|
189
|
+
canDelete(entity = null) {
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get fields schema
|
|
195
|
+
*/
|
|
196
|
+
get fields() {
|
|
197
|
+
return this._fields
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get a specific field config
|
|
202
|
+
* @param {string} fieldName
|
|
203
|
+
* @returns {object|undefined}
|
|
204
|
+
*/
|
|
205
|
+
getFieldConfig(fieldName) {
|
|
206
|
+
return this._fields[fieldName]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get initial data for a new entity based on field defaults
|
|
211
|
+
* @returns {object}
|
|
212
|
+
*/
|
|
213
|
+
getInitialData() {
|
|
214
|
+
const data = {}
|
|
215
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
|
|
216
|
+
if (fieldConfig.default !== undefined) {
|
|
217
|
+
data[fieldName] = typeof fieldConfig.default === 'function'
|
|
218
|
+
? fieldConfig.default()
|
|
219
|
+
: fieldConfig.default
|
|
220
|
+
} else {
|
|
221
|
+
// Type-based defaults
|
|
222
|
+
switch (fieldConfig.type) {
|
|
223
|
+
case 'boolean':
|
|
224
|
+
data[fieldName] = false
|
|
225
|
+
break
|
|
226
|
+
case 'number':
|
|
227
|
+
data[fieldName] = null
|
|
228
|
+
break
|
|
229
|
+
case 'array':
|
|
230
|
+
data[fieldName] = []
|
|
231
|
+
break
|
|
232
|
+
case 'object':
|
|
233
|
+
data[fieldName] = {}
|
|
234
|
+
break
|
|
235
|
+
default:
|
|
236
|
+
data[fieldName] = ''
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return data
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get field names that are required
|
|
245
|
+
* @returns {string[]}
|
|
246
|
+
*/
|
|
247
|
+
getRequiredFields() {
|
|
248
|
+
return Object.entries(this._fields)
|
|
249
|
+
.filter(([, config]) => config.required)
|
|
250
|
+
.map(([name]) => name)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get fields that should appear in list view
|
|
255
|
+
* @returns {Array<{name: string, ...config}>}
|
|
256
|
+
*/
|
|
257
|
+
getListFields() {
|
|
258
|
+
return Object.entries(this._fields)
|
|
259
|
+
.filter(([, config]) => config.listable !== false)
|
|
260
|
+
.map(([name, config]) => ({ name, ...config }))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get fields that should appear in form view
|
|
265
|
+
* @returns {Array<{name: string, ...config}>}
|
|
266
|
+
*/
|
|
267
|
+
getFormFields() {
|
|
268
|
+
return Object.entries(this._fields)
|
|
269
|
+
.filter(([, config]) => config.editable !== false)
|
|
270
|
+
.map(([name, config]) => ({ name, ...config }))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* List entities with pagination/filtering
|
|
275
|
+
* @param {object} params - { page, page_size, filters, sort_by, sort_order }
|
|
276
|
+
* @returns {Promise<{ items: Array, total: number }>}
|
|
277
|
+
*/
|
|
278
|
+
async list(params = {}) {
|
|
279
|
+
if (this.storage) {
|
|
280
|
+
return this.storage.list(params)
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`[EntityManager:${this.name}] list() not implemented`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get a single entity by ID
|
|
287
|
+
* @param {string|number} id
|
|
288
|
+
* @returns {Promise<object>}
|
|
289
|
+
*/
|
|
290
|
+
async get(id) {
|
|
291
|
+
if (this.storage) {
|
|
292
|
+
return this.storage.get(id)
|
|
293
|
+
}
|
|
294
|
+
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get multiple entities by IDs (batch fetch)
|
|
299
|
+
* @param {Array<string|number>} ids
|
|
300
|
+
* @returns {Promise<Array<object>>}
|
|
301
|
+
*/
|
|
302
|
+
async getMany(ids) {
|
|
303
|
+
if (!ids || ids.length === 0) return []
|
|
304
|
+
if (this.storage?.getMany) {
|
|
305
|
+
return this.storage.getMany(ids)
|
|
306
|
+
}
|
|
307
|
+
// Fallback: parallel get calls
|
|
308
|
+
return Promise.all(ids.map(id => this.get(id).catch(() => null)))
|
|
309
|
+
.then(results => results.filter(Boolean))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create a new entity
|
|
314
|
+
* @param {object} data
|
|
315
|
+
* @returns {Promise<object>} - The created entity
|
|
316
|
+
*/
|
|
317
|
+
async create(data) {
|
|
318
|
+
if (this.storage) {
|
|
319
|
+
return this.storage.create(data)
|
|
320
|
+
}
|
|
321
|
+
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Update an entity (PUT - full replacement)
|
|
326
|
+
* @param {string|number} id
|
|
327
|
+
* @param {object} data
|
|
328
|
+
* @returns {Promise<object>}
|
|
329
|
+
*/
|
|
330
|
+
async update(id, data) {
|
|
331
|
+
if (this.storage) {
|
|
332
|
+
return this.storage.update(id, data)
|
|
333
|
+
}
|
|
334
|
+
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Partially update an entity (PATCH)
|
|
339
|
+
* @param {string|number} id
|
|
340
|
+
* @param {object} data
|
|
341
|
+
* @returns {Promise<object>}
|
|
342
|
+
*/
|
|
343
|
+
async patch(id, data) {
|
|
344
|
+
if (this.storage) {
|
|
345
|
+
return this.storage.patch(id, data)
|
|
346
|
+
}
|
|
347
|
+
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Delete an entity
|
|
352
|
+
* @param {string|number} id
|
|
353
|
+
* @returns {Promise<void>}
|
|
354
|
+
*/
|
|
355
|
+
async delete(id) {
|
|
356
|
+
if (this.storage) {
|
|
357
|
+
return this.storage.delete(id)
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Generic request for special operations
|
|
364
|
+
* @param {string} method - 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'
|
|
365
|
+
* @param {string} path - Relative path
|
|
366
|
+
* @param {object} options - { data, params, headers }
|
|
367
|
+
* @returns {Promise<any>}
|
|
368
|
+
*/
|
|
369
|
+
async request(method, path, options = {}) {
|
|
370
|
+
if (this.storage?.request) {
|
|
371
|
+
return this.storage.request(method, path, options)
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`[EntityManager:${this.name}] request() not implemented`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Hook: called when manager is registered with orchestrator
|
|
378
|
+
* Override to perform initialization
|
|
379
|
+
*/
|
|
380
|
+
onRegister(orchestrator) {
|
|
381
|
+
this._orchestrator = orchestrator
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============ RELATIONS ============
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get child relation config
|
|
388
|
+
* @param {string} childName - Child relation name
|
|
389
|
+
* @returns {object|undefined}
|
|
390
|
+
*/
|
|
391
|
+
getChildConfig(childName) {
|
|
392
|
+
return this._children[childName]
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Get all child relation names
|
|
397
|
+
* @returns {string[]}
|
|
398
|
+
*/
|
|
399
|
+
getChildNames() {
|
|
400
|
+
return Object.keys(this._children)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get parent relation config
|
|
405
|
+
* @returns {object|null}
|
|
406
|
+
*/
|
|
407
|
+
getParentConfig() {
|
|
408
|
+
return this._parent
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* List children of a parent entity
|
|
413
|
+
* @param {string|number} parentId - Parent entity ID
|
|
414
|
+
* @param {string} childName - Child relation name
|
|
415
|
+
* @param {object} params - List params
|
|
416
|
+
* @returns {Promise<{ items: Array, total: number }>}
|
|
417
|
+
*/
|
|
418
|
+
async listChildren(parentId, childName, params = {}) {
|
|
419
|
+
const childConfig = this._children[childName]
|
|
420
|
+
if (!childConfig) {
|
|
421
|
+
throw new Error(`[EntityManager:${this.name}] Unknown child relation "${childName}"`)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Build endpoint path
|
|
425
|
+
const childEndpoint = childConfig.endpoint || `${parentId}/${childName}`
|
|
426
|
+
|
|
427
|
+
if (this.storage?.request) {
|
|
428
|
+
return this.storage.request('GET', childEndpoint, { params })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
throw new Error(`[EntityManager:${this.name}] listChildren() requires storage with request()`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get a specific child entity
|
|
436
|
+
* @param {string|number} parentId - Parent entity ID
|
|
437
|
+
* @param {string} childName - Child relation name
|
|
438
|
+
* @param {string|number} childId - Child entity ID
|
|
439
|
+
* @returns {Promise<object>}
|
|
440
|
+
*/
|
|
441
|
+
async getChild(parentId, childName, childId) {
|
|
442
|
+
const childConfig = this._children[childName]
|
|
443
|
+
if (!childConfig) {
|
|
444
|
+
throw new Error(`[EntityManager:${this.name}] Unknown child relation "${childName}"`)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const childEndpoint = childConfig.endpoint || `${parentId}/${childName}`
|
|
448
|
+
|
|
449
|
+
if (this.storage?.request) {
|
|
450
|
+
return this.storage.request('GET', `${childEndpoint}/${childId}`)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
throw new Error(`[EntityManager:${this.name}] getChild() requires storage with request()`)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Create a child entity
|
|
458
|
+
* @param {string|number} parentId - Parent entity ID
|
|
459
|
+
* @param {string} childName - Child relation name
|
|
460
|
+
* @param {object} data - Child entity data
|
|
461
|
+
* @returns {Promise<object>}
|
|
462
|
+
*/
|
|
463
|
+
async createChild(parentId, childName, data) {
|
|
464
|
+
const childConfig = this._children[childName]
|
|
465
|
+
if (!childConfig) {
|
|
466
|
+
throw new Error(`[EntityManager:${this.name}] Unknown child relation "${childName}"`)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const childEndpoint = childConfig.endpoint || `${parentId}/${childName}`
|
|
470
|
+
|
|
471
|
+
if (this.storage?.request) {
|
|
472
|
+
return this.storage.request('POST', childEndpoint, { data })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
throw new Error(`[EntityManager:${this.name}] createChild() requires storage with request()`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Delete a child entity
|
|
480
|
+
* @param {string|number} parentId - Parent entity ID
|
|
481
|
+
* @param {string} childName - Child relation name
|
|
482
|
+
* @param {string|number} childId - Child entity ID
|
|
483
|
+
* @returns {Promise<void>}
|
|
484
|
+
*/
|
|
485
|
+
async deleteChild(parentId, childName, childId) {
|
|
486
|
+
const childConfig = this._children[childName]
|
|
487
|
+
if (!childConfig) {
|
|
488
|
+
throw new Error(`[EntityManager:${this.name}] Unknown child relation "${childName}"`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const childEndpoint = childConfig.endpoint || `${parentId}/${childName}`
|
|
492
|
+
|
|
493
|
+
if (this.storage?.request) {
|
|
494
|
+
return this.storage.request('DELETE', `${childEndpoint}/${childId}`)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
throw new Error(`[EntityManager:${this.name}] deleteChild() requires storage with request()`)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Get the parent entity's manager
|
|
502
|
+
* @returns {EntityManager|null}
|
|
503
|
+
*/
|
|
504
|
+
getParentManager() {
|
|
505
|
+
if (!this._parent || !this._orchestrator) return null
|
|
506
|
+
return this._orchestrator.get(this._parent.entity)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Get a child entity's manager (from orchestrator)
|
|
511
|
+
* @param {string} childName - Child relation name
|
|
512
|
+
* @returns {EntityManager|null}
|
|
513
|
+
*/
|
|
514
|
+
getChildManager(childName) {
|
|
515
|
+
const childConfig = this._children[childName]
|
|
516
|
+
if (!childConfig || !this._orchestrator) return null
|
|
517
|
+
return this._orchestrator.get(childConfig.entity)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Hook: called when orchestrator is disposed
|
|
522
|
+
* Override to perform cleanup
|
|
523
|
+
*/
|
|
524
|
+
onDispose() {
|
|
525
|
+
// Override in subclass if needed
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Factory function to create an EntityManager
|
|
531
|
+
*
|
|
532
|
+
* @param {object} options
|
|
533
|
+
* @param {string} options.name - Entity name
|
|
534
|
+
* @param {object} [options.storage] - Storage adapter instance
|
|
535
|
+
* @param {string} [options.idField='id'] - Field name for entity ID
|
|
536
|
+
* @returns {EntityManager}
|
|
537
|
+
*/
|
|
538
|
+
export function createEntityManager(options) {
|
|
539
|
+
return new EntityManager(options)
|
|
540
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApiStorage - REST API storage adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the storage interface for REST APIs.
|
|
5
|
+
* Expects standard response format: { items: [], total: number, page: number }
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* import axios from 'axios'
|
|
10
|
+
*
|
|
11
|
+
* const storage = new ApiStorage({
|
|
12
|
+
* endpoint: '/users',
|
|
13
|
+
* client: axios.create({ baseURL: '/api' })
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Or with callback for dynamic client
|
|
17
|
+
* const storage = new ApiStorage({
|
|
18
|
+
* endpoint: '/users',
|
|
19
|
+
* getClient: () => inject('apiClient')
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class ApiStorage {
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
endpoint,
|
|
27
|
+
client = null,
|
|
28
|
+
getClient = null,
|
|
29
|
+
// Response format configuration
|
|
30
|
+
responseItemsKey = 'items',
|
|
31
|
+
responseTotalKey = 'total'
|
|
32
|
+
} = options
|
|
33
|
+
|
|
34
|
+
this.endpoint = endpoint
|
|
35
|
+
this._client = client
|
|
36
|
+
this._getClient = getClient
|
|
37
|
+
this.responseItemsKey = responseItemsKey
|
|
38
|
+
this.responseTotalKey = responseTotalKey
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get client() {
|
|
42
|
+
if (this._getClient) {
|
|
43
|
+
return this._getClient()
|
|
44
|
+
}
|
|
45
|
+
return this._client
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set client(value) {
|
|
49
|
+
this._client = value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* List entities with pagination/filtering
|
|
54
|
+
* @param {object} params - { page, page_size, filters, sort_by, sort_order }
|
|
55
|
+
* @returns {Promise<{ items: Array, total: number }>}
|
|
56
|
+
*/
|
|
57
|
+
async list(params = {}) {
|
|
58
|
+
const { page = 1, page_size = 20, ...filters } = params
|
|
59
|
+
const response = await this.client.get(this.endpoint, {
|
|
60
|
+
params: { page, page_size, ...filters }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const data = response.data
|
|
64
|
+
return {
|
|
65
|
+
items: data[this.responseItemsKey] || data.items || data,
|
|
66
|
+
total: data[this.responseTotalKey] || data.total || (Array.isArray(data) ? data.length : 0)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get a single entity by ID
|
|
72
|
+
* @param {string|number} id
|
|
73
|
+
* @returns {Promise<object>}
|
|
74
|
+
*/
|
|
75
|
+
async get(id) {
|
|
76
|
+
const response = await this.client.get(`${this.endpoint}/${id}`)
|
|
77
|
+
return response.data
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a new entity
|
|
82
|
+
* @param {object} data
|
|
83
|
+
* @returns {Promise<object>}
|
|
84
|
+
*/
|
|
85
|
+
async create(data) {
|
|
86
|
+
const response = await this.client.post(this.endpoint, data)
|
|
87
|
+
return response.data
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Update an entity (PUT - full replacement)
|
|
92
|
+
* @param {string|number} id
|
|
93
|
+
* @param {object} data
|
|
94
|
+
* @returns {Promise<object>}
|
|
95
|
+
*/
|
|
96
|
+
async update(id, data) {
|
|
97
|
+
const response = await this.client.put(`${this.endpoint}/${id}`, data)
|
|
98
|
+
return response.data
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Partially update an entity (PATCH)
|
|
103
|
+
* @param {string|number} id
|
|
104
|
+
* @param {object} data
|
|
105
|
+
* @returns {Promise<object>}
|
|
106
|
+
*/
|
|
107
|
+
async patch(id, data) {
|
|
108
|
+
const response = await this.client.patch(`${this.endpoint}/${id}`, data)
|
|
109
|
+
return response.data
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Delete an entity
|
|
114
|
+
* @param {string|number} id
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
async delete(id) {
|
|
118
|
+
await this.client.delete(`${this.endpoint}/${id}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generic request for special operations
|
|
123
|
+
* @param {string} method - 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'
|
|
124
|
+
* @param {string} path - Relative path (appended to endpoint)
|
|
125
|
+
* @param {object} options - { data, params, headers }
|
|
126
|
+
* @returns {Promise<any>}
|
|
127
|
+
*/
|
|
128
|
+
async request(method, path, options = {}) {
|
|
129
|
+
const url = path.startsWith('/') ? path : `${this.endpoint}/${path}`
|
|
130
|
+
const response = await this.client.request({
|
|
131
|
+
method,
|
|
132
|
+
url,
|
|
133
|
+
data: options.data,
|
|
134
|
+
params: options.params,
|
|
135
|
+
headers: options.headers
|
|
136
|
+
})
|
|
137
|
+
return response.data
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Factory function to create an ApiStorage
|
|
143
|
+
*/
|
|
144
|
+
export function createApiStorage(options) {
|
|
145
|
+
return new ApiStorage(options)
|
|
146
|
+
}
|