qdadm 0.51.4 → 0.51.5
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/package.json +1 -1
- package/src/components/SeverityTag.vue +1 -1
- package/src/components/lists/ListPage.vue +1 -1
- package/src/components/pages/LoginPage.vue +1 -1
- package/src/composables/index.js +1 -1
- package/src/composables/{useListPageBuilder.js → useListPage.js} +44 -10
- package/src/debug/EntitiesCollector.js +72 -1
- package/src/debug/components/panels/EntitiesPanel.vue +62 -0
- package/src/entity/EntityManager.js +80 -15
- package/src/entity/storage/ApiStorage.js +98 -10
- package/src/query/FilterQuery.js +1 -1
- package/src/security/pages/RoleList.vue +2 -2
package/package.json
CHANGED
|
@@ -46,7 +46,7 @@ const props = defineProps({
|
|
|
46
46
|
})
|
|
47
47
|
|
|
48
48
|
const orchestrator = inject('qdadmOrchestrator')
|
|
49
|
-
// Auto-discover entity from page context (provided by
|
|
49
|
+
// Auto-discover entity from page context (provided by useListPage, useBareForm, etc.)
|
|
50
50
|
const mainEntity = inject('mainEntity', null)
|
|
51
51
|
|
|
52
52
|
const resolvedEntity = computed(() => props.entity || mainEntity)
|
package/src/composables/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export { useDirtyState } from './useDirtyState'
|
|
|
9
9
|
export { useForm } from './useForm'
|
|
10
10
|
export { useEntityItemFormPage, useEntityItemFormPage as useFormPageBuilder } from './useEntityItemFormPage'
|
|
11
11
|
export * from './useJsonSyntax'
|
|
12
|
-
export {
|
|
12
|
+
export { useListPage, PAGE_SIZE_OPTIONS } from './useListPage'
|
|
13
13
|
export { usePageTitle } from './usePageTitle'
|
|
14
14
|
export { useApp } from './useApp'
|
|
15
15
|
export { useAuth } from './useAuth'
|
|
@@ -3,6 +3,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|
|
3
3
|
import { useToast } from 'primevue/usetoast'
|
|
4
4
|
import { useConfirm } from 'primevue/useconfirm'
|
|
5
5
|
import { useHooks } from './useHooks.js'
|
|
6
|
+
import { useEntityItemPage } from './useEntityItemPage.js'
|
|
6
7
|
import { FilterQuery } from '../query/FilterQuery.js'
|
|
7
8
|
|
|
8
9
|
// Cookie utilities for pagination persistence
|
|
@@ -58,7 +59,7 @@ function setSessionFilters(key, filters) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
+
* useListPage - Unified procedural builder for CRUD list pages
|
|
62
63
|
*
|
|
63
64
|
* Provides a declarative/procedural API to build list pages with:
|
|
64
65
|
* - Cards zone (stats, custom content)
|
|
@@ -102,14 +103,14 @@ function setSessionFilters(key, filters) {
|
|
|
102
103
|
* ## Basic Usage
|
|
103
104
|
*
|
|
104
105
|
* ```js
|
|
105
|
-
* const list =
|
|
106
|
+
* const list = useListPage({ entity: 'domains' })
|
|
106
107
|
* list.addFilter('status', { options: [...] })
|
|
107
108
|
* list.setSearch({ fields: ['name', 'email'] })
|
|
108
109
|
* list.addCreateAction()
|
|
109
110
|
* list.addEditAction()
|
|
110
111
|
* ```
|
|
111
112
|
*/
|
|
112
|
-
export function
|
|
113
|
+
export function useListPage(config = {}) {
|
|
113
114
|
const {
|
|
114
115
|
entity,
|
|
115
116
|
dataKey,
|
|
@@ -156,6 +157,35 @@ export function useListPageBuilder(config = {}) {
|
|
|
156
157
|
// Provide entity context for child components (e.g., SeverityTag auto-discovery)
|
|
157
158
|
provide('mainEntity', entity)
|
|
158
159
|
|
|
160
|
+
// ============ PARENT CHAIN SUPPORT ============
|
|
161
|
+
// When list is a child of a parent entity (e.g., /bots/:id/commands),
|
|
162
|
+
// we use useEntityItemPage for the PARENT to:
|
|
163
|
+
// - Load parent entity data
|
|
164
|
+
// - Set breadcrumbs for parent
|
|
165
|
+
// - Provide parent context to the list
|
|
166
|
+
|
|
167
|
+
const parentConfig = computed(() => route.meta?.parent || null)
|
|
168
|
+
|
|
169
|
+
// Create useEntityItemPage for PARENT entity (not for list's own entity)
|
|
170
|
+
// Only when parentConfig exists
|
|
171
|
+
let parentPage = null
|
|
172
|
+
if (route.meta?.parent) {
|
|
173
|
+
const parentMeta = route.meta.parent
|
|
174
|
+
parentPage = useEntityItemPage({
|
|
175
|
+
entity: parentMeta.entity,
|
|
176
|
+
loadOnMount: true, // Auto-load parent on mount
|
|
177
|
+
breadcrumb: true, // Parent sets its breadcrumb
|
|
178
|
+
idParam: parentMeta.param, // Use parent's param (e.g., 'id' for /bots/:id)
|
|
179
|
+
autoLoadParent: true // Support grandparent chain
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Parent data and loading state (null if no parent)
|
|
184
|
+
const parentData = computed(() => parentPage?.data.value || null)
|
|
185
|
+
const parentId = computed(() => parentPage?.entityId.value || null)
|
|
186
|
+
const parentLoading = computed(() => parentPage?.loading.value || false)
|
|
187
|
+
const parentChain = computed(() => parentPage?.parentChain.value || new Map())
|
|
188
|
+
|
|
159
189
|
// Read config from manager with option overrides
|
|
160
190
|
const entityName = config.entityName ?? manager.label
|
|
161
191
|
const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
|
|
@@ -1079,13 +1109,9 @@ export function useListPageBuilder(config = {}) {
|
|
|
1079
1109
|
}
|
|
1080
1110
|
}
|
|
1081
1111
|
|
|
1082
|
-
// Auto-add parent filter from route config
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
const parentId = route.params[parentConfig.param]
|
|
1086
|
-
if (parentId) {
|
|
1087
|
-
filters[parentConfig.foreignKey] = parentId
|
|
1088
|
-
}
|
|
1112
|
+
// Auto-add parent filter from route config (uses parentPage from useEntityItemPage)
|
|
1113
|
+
if (parentConfig.value?.foreignKey && parentId.value) {
|
|
1114
|
+
filters[parentConfig.value.foreignKey] = parentId.value
|
|
1089
1115
|
}
|
|
1090
1116
|
|
|
1091
1117
|
if (Object.keys(filters).length > 0) {
|
|
@@ -1645,6 +1671,14 @@ export function useListPageBuilder(config = {}) {
|
|
|
1645
1671
|
// Manager access
|
|
1646
1672
|
manager,
|
|
1647
1673
|
|
|
1674
|
+
// Parent chain (when list is child of parent entity)
|
|
1675
|
+
parentConfig,
|
|
1676
|
+
parentId,
|
|
1677
|
+
parentData,
|
|
1678
|
+
parentLoading,
|
|
1679
|
+
parentChain,
|
|
1680
|
+
parentPage, // Full useEntityItemPage instance for parent (or null)
|
|
1681
|
+
|
|
1648
1682
|
// State
|
|
1649
1683
|
items,
|
|
1650
1684
|
displayItems, // Use this for rendering (handles local/API filtering)
|
|
@@ -231,9 +231,14 @@ export class EntitiesCollector extends Collector {
|
|
|
231
231
|
storage: {
|
|
232
232
|
type: storage?.constructor?.storageName || storage?.constructor?.name || 'None',
|
|
233
233
|
endpoint: storage?.endpoint || storage?._endpoint || null,
|
|
234
|
-
capabilities: storage?.capabilities || storage?.constructor?.capabilities || {}
|
|
234
|
+
capabilities: storage?.capabilities || storage?.constructor?.capabilities || {},
|
|
235
|
+
hasNormalize: !!(storage?._normalize),
|
|
236
|
+
hasDenormalize: !!(storage?._denormalize)
|
|
235
237
|
},
|
|
236
238
|
|
|
239
|
+
// Multi-storage info
|
|
240
|
+
multiStorage: this._detectMultiStorage(manager),
|
|
241
|
+
|
|
237
242
|
// Cache info
|
|
238
243
|
cache: {
|
|
239
244
|
enabled: cache.enabled ?? false,
|
|
@@ -311,6 +316,72 @@ export class EntitiesCollector extends Collector {
|
|
|
311
316
|
}
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Detect if a manager uses multi-storage pattern
|
|
321
|
+
* @param {EntityManager} manager - Manager instance
|
|
322
|
+
* @returns {object|null} Multi-storage info or null
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_detectMultiStorage(manager) {
|
|
326
|
+
// Check if resolveStorage is overridden (not the base implementation)
|
|
327
|
+
const hasCustomResolve = manager.resolveStorage &&
|
|
328
|
+
manager.resolveStorage.toString() !== 'resolveStorage(method, context) {\n return { storage: this.storage };\n }'
|
|
329
|
+
|
|
330
|
+
if (!hasCustomResolve) {
|
|
331
|
+
// Also check by looking for additional storage properties
|
|
332
|
+
const additionalStorages = this._findAdditionalStorages(manager)
|
|
333
|
+
if (additionalStorages.length === 0) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Find all storage properties on the manager
|
|
339
|
+
const storages = this._findAdditionalStorages(manager)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
enabled: true,
|
|
343
|
+
storages: storages.map(s => ({
|
|
344
|
+
name: s.name,
|
|
345
|
+
type: s.storage?.constructor?.storageName || s.storage?.constructor?.name || 'Unknown',
|
|
346
|
+
endpoint: s.storage?.endpoint || null,
|
|
347
|
+
hasNormalize: !!(s.storage?._normalize),
|
|
348
|
+
hasDenormalize: !!(s.storage?._denormalize)
|
|
349
|
+
}))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Find additional storage instances on a manager
|
|
355
|
+
* @param {EntityManager} manager - Manager instance
|
|
356
|
+
* @returns {Array<{name: string, storage: object}>}
|
|
357
|
+
* @private
|
|
358
|
+
*/
|
|
359
|
+
_findAdditionalStorages(manager) {
|
|
360
|
+
const storages = []
|
|
361
|
+
const mainStorage = manager.storage
|
|
362
|
+
|
|
363
|
+
// Look for properties that look like storages
|
|
364
|
+
for (const key of Object.keys(manager)) {
|
|
365
|
+
// Skip private and known non-storage properties
|
|
366
|
+
if (key.startsWith('_') || key === 'storage') continue
|
|
367
|
+
|
|
368
|
+
const value = manager[key]
|
|
369
|
+
if (!value || typeof value !== 'object') continue
|
|
370
|
+
|
|
371
|
+
// Check if it looks like a storage (has list, get, create methods or endpoint)
|
|
372
|
+
const isStorage = (
|
|
373
|
+
(typeof value.list === 'function' && typeof value.get === 'function') ||
|
|
374
|
+
(value.endpoint && typeof value.fetch === 'function')
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if (isStorage && value !== mainStorage) {
|
|
378
|
+
storages.push({ name: key, storage: value })
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return storages
|
|
383
|
+
}
|
|
384
|
+
|
|
314
385
|
/**
|
|
315
386
|
* Get cached items from a manager
|
|
316
387
|
* @param {EntityManager} manager - Manager instance
|
|
@@ -168,6 +168,24 @@ function getCapabilityLabel(cap) {
|
|
|
168
168
|
<span class="entity-key">Storage:</span>
|
|
169
169
|
<span class="entity-value">{{ entity.storage.type }}</span>
|
|
170
170
|
<span v-if="entity.storage.endpoint" class="entity-endpoint">{{ entity.storage.endpoint }}</span>
|
|
171
|
+
<span v-if="entity.storage.hasNormalize || entity.storage.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
172
|
+
<i class="pi pi-arrows-h" />
|
|
173
|
+
</span>
|
|
174
|
+
<span v-if="entity.multiStorage?.enabled" class="entity-multi-badge" title="Multi-storage routing">
|
|
175
|
+
<i class="pi pi-sitemap" />
|
|
176
|
+
+{{ entity.multiStorage.storages.length }}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<!-- Multi-storage details -->
|
|
180
|
+
<div v-if="entity.multiStorage?.enabled && entity.multiStorage.storages.length > 0" class="entity-multi-storages">
|
|
181
|
+
<div v-for="s in entity.multiStorage.storages" :key="s.name" class="entity-multi-storage">
|
|
182
|
+
<span class="entity-storage-name">{{ s.name }}</span>
|
|
183
|
+
<span class="entity-storage-type">{{ s.type }}</span>
|
|
184
|
+
<span v-if="s.endpoint" class="entity-endpoint">{{ s.endpoint }}</span>
|
|
185
|
+
<span v-if="s.hasNormalize || s.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
186
|
+
<i class="pi pi-arrows-h" />
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
171
189
|
</div>
|
|
172
190
|
<div v-if="entity.storage.capabilities && Object.keys(entity.storage.capabilities).length > 0" class="entity-row">
|
|
173
191
|
<span class="entity-key">Caps:</span>
|
|
@@ -580,6 +598,50 @@ function getCapabilityLabel(cap) {
|
|
|
580
598
|
background: #3f3f46;
|
|
581
599
|
border-radius: 2px;
|
|
582
600
|
}
|
|
601
|
+
.entity-normalize-badge {
|
|
602
|
+
display: inline-flex;
|
|
603
|
+
align-items: center;
|
|
604
|
+
justify-content: center;
|
|
605
|
+
width: 18px;
|
|
606
|
+
height: 18px;
|
|
607
|
+
background: rgba(139, 92, 246, 0.2);
|
|
608
|
+
color: #a78bfa;
|
|
609
|
+
border-radius: 3px;
|
|
610
|
+
font-size: 10px;
|
|
611
|
+
}
|
|
612
|
+
.entity-multi-badge {
|
|
613
|
+
display: inline-flex;
|
|
614
|
+
align-items: center;
|
|
615
|
+
gap: 3px;
|
|
616
|
+
padding: 2px 6px;
|
|
617
|
+
background: rgba(59, 130, 246, 0.2);
|
|
618
|
+
color: #60a5fa;
|
|
619
|
+
border-radius: 3px;
|
|
620
|
+
font-size: 10px;
|
|
621
|
+
font-weight: 600;
|
|
622
|
+
}
|
|
623
|
+
.entity-multi-storages {
|
|
624
|
+
margin-left: 66px;
|
|
625
|
+
margin-top: 4px;
|
|
626
|
+
margin-bottom: 6px;
|
|
627
|
+
padding-left: 8px;
|
|
628
|
+
border-left: 2px solid #3b82f6;
|
|
629
|
+
}
|
|
630
|
+
.entity-multi-storage {
|
|
631
|
+
display: flex;
|
|
632
|
+
align-items: center;
|
|
633
|
+
gap: 6px;
|
|
634
|
+
padding: 3px 0;
|
|
635
|
+
font-size: 10px;
|
|
636
|
+
}
|
|
637
|
+
.entity-storage-name {
|
|
638
|
+
color: #60a5fa;
|
|
639
|
+
font-weight: 500;
|
|
640
|
+
min-width: 80px;
|
|
641
|
+
}
|
|
642
|
+
.entity-storage-type {
|
|
643
|
+
color: #a1a1aa;
|
|
644
|
+
}
|
|
583
645
|
.entity-capabilities {
|
|
584
646
|
display: flex;
|
|
585
647
|
gap: 4px;
|
|
@@ -304,6 +304,61 @@ export class EntityManager {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// ============ STORAGE RESOLUTION ============
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Resolve which storage to use for an operation
|
|
311
|
+
*
|
|
312
|
+
* Override in subclass to route to different storages based on context.
|
|
313
|
+
* This enables multi-endpoint patterns like:
|
|
314
|
+
* - /api/commands (global)
|
|
315
|
+
* - /api/bots/:id/commands (scoped to bot)
|
|
316
|
+
*
|
|
317
|
+
* The default implementation returns the single configured storage.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} method - Operation: 'list', 'get', 'create', 'update', 'delete'
|
|
320
|
+
* @param {object} [context] - Routing context
|
|
321
|
+
* @param {Array<{entity: string, id: string|number}>} [context.parentChain] - Parent chain from root to direct parent
|
|
322
|
+
* @returns {{ storage: IStorage, path?: string, mappingKey?: string }}
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* // Single storage (default, no change needed)
|
|
326
|
+
* manager.resolveStorage('list') // { storage: this.storage }
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* // Access direct parent (last in chain)
|
|
330
|
+
* const parent = context?.parentChain?.at(-1)
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* // Multi-storage routing in subclass
|
|
334
|
+
* resolveStorage(method, context) {
|
|
335
|
+
* const parent = context?.parentChain?.at(-1)
|
|
336
|
+
* if (parent?.entity === 'bots') {
|
|
337
|
+
* return {
|
|
338
|
+
* storage: this.botStorage,
|
|
339
|
+
* path: `/${parent.id}/commands`
|
|
340
|
+
* }
|
|
341
|
+
* }
|
|
342
|
+
* return { storage: this.storage }
|
|
343
|
+
* }
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* // Nested parents: /users/:userId/posts/:postId/comments
|
|
347
|
+
* resolveStorage(method, context) {
|
|
348
|
+
* const chain = context?.parentChain || []
|
|
349
|
+
* if (chain.length === 2 && chain[0].entity === 'users' && chain[1].entity === 'posts') {
|
|
350
|
+
* return {
|
|
351
|
+
* storage: this.nestedStorage,
|
|
352
|
+
* path: `/${chain[0].id}/posts/${chain[1].id}/comments`
|
|
353
|
+
* }
|
|
354
|
+
* }
|
|
355
|
+
* return { storage: this.storage }
|
|
356
|
+
* }
|
|
357
|
+
*/
|
|
358
|
+
resolveStorage(method, context) {
|
|
359
|
+
return { storage: this.storage }
|
|
360
|
+
}
|
|
361
|
+
|
|
307
362
|
// ============ METADATA ACCESSORS ============
|
|
308
363
|
|
|
309
364
|
/**
|
|
@@ -857,10 +912,12 @@ export class EntityManager {
|
|
|
857
912
|
* @param {string} [params.sort_by] - Sort field
|
|
858
913
|
* @param {string} [params.sort_order] - 'asc' or 'desc'
|
|
859
914
|
* @param {boolean} [params.cacheSafe] - If true, allow caching even with filters
|
|
915
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
860
916
|
* @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
|
|
861
917
|
*/
|
|
862
|
-
async list(params = {}) {
|
|
863
|
-
|
|
918
|
+
async list(params = {}, context) {
|
|
919
|
+
const { storage } = this.resolveStorage('list', context)
|
|
920
|
+
if (!storage) {
|
|
864
921
|
throw new Error(`[EntityManager:${this.name}] list() not implemented`)
|
|
865
922
|
}
|
|
866
923
|
|
|
@@ -893,7 +950,7 @@ export class EntityManager {
|
|
|
893
950
|
if (!_internal) this._stats.cacheMisses++
|
|
894
951
|
|
|
895
952
|
// 2. Fetch from API (storage normalizes response to { items, total })
|
|
896
|
-
const response = await
|
|
953
|
+
const response = await storage.list(queryParams)
|
|
897
954
|
const items = response.items || []
|
|
898
955
|
const total = response.total ?? items.length
|
|
899
956
|
|
|
@@ -937,9 +994,11 @@ export class EntityManager {
|
|
|
937
994
|
* Otherwise fetches from storage.
|
|
938
995
|
*
|
|
939
996
|
* @param {string|number} id
|
|
997
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
940
998
|
* @returns {Promise<object>}
|
|
941
999
|
*/
|
|
942
|
-
async get(id) {
|
|
1000
|
+
async get(id, context) {
|
|
1001
|
+
const { storage } = this.resolveStorage('get', context)
|
|
943
1002
|
this._stats.get++
|
|
944
1003
|
|
|
945
1004
|
// Try cache first if valid and complete
|
|
@@ -954,8 +1013,8 @@ export class EntityManager {
|
|
|
954
1013
|
|
|
955
1014
|
// Fallback to storage
|
|
956
1015
|
this._stats.cacheMisses++
|
|
957
|
-
if (
|
|
958
|
-
return
|
|
1016
|
+
if (storage) {
|
|
1017
|
+
return storage.get(id)
|
|
959
1018
|
}
|
|
960
1019
|
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
961
1020
|
}
|
|
@@ -1006,17 +1065,19 @@ export class EntityManager {
|
|
|
1006
1065
|
* - postsave: After successful storage.create(), for side effects
|
|
1007
1066
|
*
|
|
1008
1067
|
* @param {object} data
|
|
1068
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1009
1069
|
* @returns {Promise<object>} - The created entity
|
|
1010
1070
|
*/
|
|
1011
|
-
async create(data) {
|
|
1071
|
+
async create(data, context) {
|
|
1072
|
+
const { storage } = this.resolveStorage('create', context)
|
|
1012
1073
|
this._stats.create++
|
|
1013
|
-
if (
|
|
1074
|
+
if (storage) {
|
|
1014
1075
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1015
1076
|
const presaveContext = this._buildPresaveContext(data, true)
|
|
1016
1077
|
await this._invokeHook('presave', presaveContext)
|
|
1017
1078
|
|
|
1018
1079
|
// Use potentially modified data from context
|
|
1019
|
-
const result = await
|
|
1080
|
+
const result = await storage.create(presaveContext.record)
|
|
1020
1081
|
this.invalidateCache()
|
|
1021
1082
|
|
|
1022
1083
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1043,17 +1104,19 @@ export class EntityManager {
|
|
|
1043
1104
|
*
|
|
1044
1105
|
* @param {string|number} id
|
|
1045
1106
|
* @param {object} data
|
|
1107
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1046
1108
|
* @returns {Promise<object>}
|
|
1047
1109
|
*/
|
|
1048
|
-
async update(id, data) {
|
|
1110
|
+
async update(id, data, context) {
|
|
1111
|
+
const { storage } = this.resolveStorage('update', context)
|
|
1049
1112
|
this._stats.update++
|
|
1050
|
-
if (
|
|
1113
|
+
if (storage) {
|
|
1051
1114
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1052
1115
|
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
1053
1116
|
await this._invokeHook('presave', presaveContext)
|
|
1054
1117
|
|
|
1055
1118
|
// Use potentially modified data from context
|
|
1056
|
-
const result = await
|
|
1119
|
+
const result = await storage.update(id, presaveContext.record)
|
|
1057
1120
|
this.invalidateCache()
|
|
1058
1121
|
|
|
1059
1122
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1115,16 +1178,18 @@ export class EntityManager {
|
|
|
1115
1178
|
* - predelete: Before storage.delete(), can throw to abort (for cascade checks)
|
|
1116
1179
|
*
|
|
1117
1180
|
* @param {string|number} id
|
|
1181
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1118
1182
|
* @returns {Promise<void>}
|
|
1119
1183
|
*/
|
|
1120
|
-
async delete(id) {
|
|
1184
|
+
async delete(id, context) {
|
|
1185
|
+
const { storage } = this.resolveStorage('delete', context)
|
|
1121
1186
|
this._stats.delete++
|
|
1122
|
-
if (
|
|
1187
|
+
if (storage) {
|
|
1123
1188
|
// Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
|
|
1124
1189
|
const predeleteContext = this._buildPredeleteContext(id)
|
|
1125
1190
|
await this._invokeHook('predelete', predeleteContext)
|
|
1126
1191
|
|
|
1127
|
-
const result = await
|
|
1192
|
+
const result = await storage.delete(id)
|
|
1128
1193
|
this.invalidateCache()
|
|
1129
1194
|
this._emitSignal('deleted', {
|
|
1130
1195
|
manager: this.name,
|
|
@@ -20,6 +20,34 @@ import { IStorage } from './IStorage.js'
|
|
|
20
20
|
* endpoint: '/users',
|
|
21
21
|
* getClient: () => inject('apiClient')
|
|
22
22
|
* })
|
|
23
|
+
*
|
|
24
|
+
* // With parameter mapping (transform filter names for API)
|
|
25
|
+
* const storage = new ApiStorage({
|
|
26
|
+
* endpoint: '/commands',
|
|
27
|
+
* client: apiClient,
|
|
28
|
+
* paramMapping: {
|
|
29
|
+
* bot_uuid: 'botUuid', // filters.bot_uuid → ?botUuid=xxx
|
|
30
|
+
* page_size: 'limit' // page_size → ?limit=xxx
|
|
31
|
+
* }
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* // With data normalization (different API format)
|
|
35
|
+
* const storage = new ApiStorage({
|
|
36
|
+
* endpoint: '/api/projects/:id/tasks',
|
|
37
|
+
* client: apiClient,
|
|
38
|
+
* // API → internal format
|
|
39
|
+
* normalize: (apiData) => ({
|
|
40
|
+
* id: apiData.task_id,
|
|
41
|
+
* title: apiData.name,
|
|
42
|
+
* status: apiData.state
|
|
43
|
+
* }),
|
|
44
|
+
* // Internal → API format
|
|
45
|
+
* denormalize: (data) => ({
|
|
46
|
+
* task_id: data.id,
|
|
47
|
+
* name: data.title,
|
|
48
|
+
* state: data.status
|
|
49
|
+
* })
|
|
50
|
+
* })
|
|
23
51
|
* ```
|
|
24
52
|
*/
|
|
25
53
|
export class ApiStorage extends IStorage {
|
|
@@ -55,7 +83,15 @@ export class ApiStorage extends IStorage {
|
|
|
55
83
|
getClient = null,
|
|
56
84
|
// Response format configuration
|
|
57
85
|
responseItemsKey = 'items',
|
|
58
|
-
responseTotalKey = 'total'
|
|
86
|
+
responseTotalKey = 'total',
|
|
87
|
+
// Parameter mapping: { clientName: apiName }
|
|
88
|
+
// Transforms filter and query param names before sending to API
|
|
89
|
+
paramMapping = {},
|
|
90
|
+
// Data normalization
|
|
91
|
+
// normalize: (apiData) => internalData - transform API response
|
|
92
|
+
// denormalize: (internalData) => apiData - transform before sending
|
|
93
|
+
normalize = null,
|
|
94
|
+
denormalize = null
|
|
59
95
|
} = options
|
|
60
96
|
|
|
61
97
|
this.endpoint = endpoint
|
|
@@ -63,6 +99,50 @@ export class ApiStorage extends IStorage {
|
|
|
63
99
|
this._getClient = getClient
|
|
64
100
|
this.responseItemsKey = responseItemsKey
|
|
65
101
|
this.responseTotalKey = responseTotalKey
|
|
102
|
+
this.paramMapping = paramMapping
|
|
103
|
+
this._normalize = normalize
|
|
104
|
+
this._denormalize = denormalize
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalize API response data to internal format
|
|
109
|
+
* @param {object|Array} data - API response data
|
|
110
|
+
* @returns {object|Array} - Normalized data
|
|
111
|
+
*/
|
|
112
|
+
_normalizeData(data) {
|
|
113
|
+
if (!this._normalize) return data
|
|
114
|
+
if (Array.isArray(data)) {
|
|
115
|
+
return data.map(item => this._normalize(item))
|
|
116
|
+
}
|
|
117
|
+
return this._normalize(data)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Denormalize internal data to API format
|
|
122
|
+
* @param {object} data - Internal data
|
|
123
|
+
* @returns {object} - API format data
|
|
124
|
+
*/
|
|
125
|
+
_denormalizeData(data) {
|
|
126
|
+
if (!this._denormalize) return data
|
|
127
|
+
return this._denormalize(data)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply parameter mapping to transform names
|
|
132
|
+
* @param {object} params - Original params
|
|
133
|
+
* @returns {object} - Params with mapped names
|
|
134
|
+
*/
|
|
135
|
+
_applyParamMapping(params) {
|
|
136
|
+
if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
|
|
137
|
+
return params
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mapped = {}
|
|
141
|
+
for (const [key, value] of Object.entries(params)) {
|
|
142
|
+
const mappedKey = this.paramMapping[key] || key
|
|
143
|
+
mapped[mappedKey] = value
|
|
144
|
+
}
|
|
145
|
+
return mapped
|
|
66
146
|
}
|
|
67
147
|
|
|
68
148
|
get client() {
|
|
@@ -88,13 +168,18 @@ export class ApiStorage extends IStorage {
|
|
|
88
168
|
*/
|
|
89
169
|
async list(params = {}) {
|
|
90
170
|
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
171
|
+
|
|
172
|
+
// Apply param mapping to filters
|
|
173
|
+
const mappedFilters = this._applyParamMapping(filters)
|
|
174
|
+
|
|
91
175
|
const response = await this.client.get(this.endpoint, {
|
|
92
|
-
params: { page, page_size, sort_by, sort_order, ...
|
|
176
|
+
params: { page, page_size, sort_by, sort_order, ...mappedFilters }
|
|
93
177
|
})
|
|
94
178
|
|
|
95
179
|
const data = response.data
|
|
180
|
+
const items = data[this.responseItemsKey] || data.items || data
|
|
96
181
|
return {
|
|
97
|
-
items:
|
|
182
|
+
items: this._normalizeData(items),
|
|
98
183
|
total: data[this.responseTotalKey] || data.total || (Array.isArray(data) ? data.length : 0)
|
|
99
184
|
}
|
|
100
185
|
}
|
|
@@ -106,7 +191,7 @@ export class ApiStorage extends IStorage {
|
|
|
106
191
|
*/
|
|
107
192
|
async get(id) {
|
|
108
193
|
const response = await this.client.get(`${this.endpoint}/${id}`)
|
|
109
|
-
return response.data
|
|
194
|
+
return this._normalizeData(response.data)
|
|
110
195
|
}
|
|
111
196
|
|
|
112
197
|
/**
|
|
@@ -115,8 +200,9 @@ export class ApiStorage extends IStorage {
|
|
|
115
200
|
* @returns {Promise<object>}
|
|
116
201
|
*/
|
|
117
202
|
async create(data) {
|
|
118
|
-
const
|
|
119
|
-
|
|
203
|
+
const apiData = this._denormalizeData(data)
|
|
204
|
+
const response = await this.client.post(this.endpoint, apiData)
|
|
205
|
+
return this._normalizeData(response.data)
|
|
120
206
|
}
|
|
121
207
|
|
|
122
208
|
/**
|
|
@@ -126,8 +212,9 @@ export class ApiStorage extends IStorage {
|
|
|
126
212
|
* @returns {Promise<object>}
|
|
127
213
|
*/
|
|
128
214
|
async update(id, data) {
|
|
129
|
-
const
|
|
130
|
-
|
|
215
|
+
const apiData = this._denormalizeData(data)
|
|
216
|
+
const response = await this.client.put(`${this.endpoint}/${id}`, apiData)
|
|
217
|
+
return this._normalizeData(response.data)
|
|
131
218
|
}
|
|
132
219
|
|
|
133
220
|
/**
|
|
@@ -137,8 +224,9 @@ export class ApiStorage extends IStorage {
|
|
|
137
224
|
* @returns {Promise<object>}
|
|
138
225
|
*/
|
|
139
226
|
async patch(id, data) {
|
|
140
|
-
const
|
|
141
|
-
|
|
227
|
+
const apiData = this._denormalizeData(data)
|
|
228
|
+
const response = await this.client.patch(`${this.endpoint}/${id}`, apiData)
|
|
229
|
+
return this._normalizeData(response.data)
|
|
142
230
|
}
|
|
143
231
|
|
|
144
232
|
/**
|
package/src/query/FilterQuery.js
CHANGED
|
@@ -102,7 +102,7 @@ export class FilterQuery {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Set the parent manager (called by
|
|
105
|
+
* Set the parent manager (called by useListPage for field source)
|
|
106
106
|
*
|
|
107
107
|
* @param {EntityManager} manager
|
|
108
108
|
* @returns {FilterQuery} this for chaining
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* RoleList - Role listing page (standard ListPage pattern)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { useListPage, ListPage, useOrchestrator } from '../../index.js'
|
|
7
7
|
import Column from 'primevue/column'
|
|
8
8
|
import Tag from 'primevue/tag'
|
|
9
9
|
import Chip from 'primevue/chip'
|
|
10
10
|
import Message from 'primevue/message'
|
|
11
11
|
|
|
12
12
|
// ============ LIST BUILDER ============
|
|
13
|
-
const list =
|
|
13
|
+
const list = useListPage({ entity: 'roles' })
|
|
14
14
|
|
|
15
15
|
// ============ SEARCH ============
|
|
16
16
|
list.setSearch({
|