qdadm 0.51.4 → 0.51.6
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/useEntityItemFormPage.js +5 -1
- package/src/composables/{useListPageBuilder.js → useListPage.js} +75 -12
- package/src/debug/EntitiesCollector.js +103 -1
- package/src/debug/components/panels/EntitiesPanel.vue +235 -39
- package/src/entity/EntityManager.js +136 -27
- package/src/entity/storage/ApiStorage.js +87 -10
- package/src/kernel/Kernel.js +14 -2
- 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'
|
|
@@ -298,7 +298,11 @@ export function useEntityItemFormPage(config = {}) {
|
|
|
298
298
|
responseData = await manager.update(entityId.value, payload)
|
|
299
299
|
}
|
|
300
300
|
} else {
|
|
301
|
-
|
|
301
|
+
// Build context with parent chain for multi-storage routing
|
|
302
|
+
const context = parentConfig.value && parentId.value
|
|
303
|
+
? { parentChain: [{ entity: parentConfig.value.entity, id: parentId.value }] }
|
|
304
|
+
: undefined
|
|
305
|
+
responseData = await manager.create(payload, context)
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
toast.add({
|
|
@@ -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,63 @@ 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
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build entity context with parentChain array for multi-storage routing
|
|
191
|
+
* Format: { parentChain: [{ entity: 'grandparent', id: '1' }, { entity: 'parent', id: '42' }] }
|
|
192
|
+
* Array is ordered from root ancestor to immediate parent
|
|
193
|
+
*/
|
|
194
|
+
const entityContext = computed(() => {
|
|
195
|
+
if (!parentConfig.value || !parentId.value) {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build chain from config (traversing from immediate parent to root)
|
|
200
|
+
const chain = []
|
|
201
|
+
let currentConfig = parentConfig.value
|
|
202
|
+
|
|
203
|
+
while (currentConfig) {
|
|
204
|
+
const entityId = route.params[currentConfig.param]
|
|
205
|
+
if (!entityId) break
|
|
206
|
+
|
|
207
|
+
chain.unshift({
|
|
208
|
+
entity: currentConfig.entity,
|
|
209
|
+
id: entityId
|
|
210
|
+
})
|
|
211
|
+
currentConfig = currentConfig.parent
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return chain.length > 0 ? { parentChain: chain } : null
|
|
215
|
+
})
|
|
216
|
+
|
|
159
217
|
// Read config from manager with option overrides
|
|
160
218
|
const entityName = config.entityName ?? manager.label
|
|
161
219
|
const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
|
|
@@ -1079,13 +1137,9 @@ export function useListPageBuilder(config = {}) {
|
|
|
1079
1137
|
}
|
|
1080
1138
|
}
|
|
1081
1139
|
|
|
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
|
-
}
|
|
1140
|
+
// Auto-add parent filter from route config (uses parentPage from useEntityItemPage)
|
|
1141
|
+
if (parentConfig.value?.foreignKey && parentId.value) {
|
|
1142
|
+
filters[parentConfig.value.foreignKey] = parentId.value
|
|
1089
1143
|
}
|
|
1090
1144
|
|
|
1091
1145
|
if (Object.keys(filters).length > 0) {
|
|
@@ -1098,9 +1152,10 @@ export function useListPageBuilder(config = {}) {
|
|
|
1098
1152
|
}
|
|
1099
1153
|
|
|
1100
1154
|
// Use manager.query() for automatic cache handling
|
|
1155
|
+
// Pass entityContext for multi-storage routing
|
|
1101
1156
|
const response = manager.query
|
|
1102
|
-
? await manager.query(params)
|
|
1103
|
-
: await manager.list(params)
|
|
1157
|
+
? await manager.query(params, { routingContext: entityContext.value })
|
|
1158
|
+
: await manager.list(params, entityContext.value)
|
|
1104
1159
|
|
|
1105
1160
|
// Track if response came from cache
|
|
1106
1161
|
fromCache.value = response.fromCache || false
|
|
@@ -1645,6 +1700,14 @@ export function useListPageBuilder(config = {}) {
|
|
|
1645
1700
|
// Manager access
|
|
1646
1701
|
manager,
|
|
1647
1702
|
|
|
1703
|
+
// Parent chain (when list is child of parent entity)
|
|
1704
|
+
parentConfig,
|
|
1705
|
+
parentId,
|
|
1706
|
+
parentData,
|
|
1707
|
+
parentLoading,
|
|
1708
|
+
parentChain,
|
|
1709
|
+
parentPage, // Full useEntityItemPage instance for parent (or null)
|
|
1710
|
+
|
|
1648
1711
|
// State
|
|
1649
1712
|
items,
|
|
1650
1713
|
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,73 @@ 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
|
+
capabilities: s.storage?.capabilities || s.storage?.constructor?.capabilities || {}
|
|
350
|
+
}))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Find additional storage instances on a manager
|
|
356
|
+
* @param {EntityManager} manager - Manager instance
|
|
357
|
+
* @returns {Array<{name: string, storage: object}>}
|
|
358
|
+
* @private
|
|
359
|
+
*/
|
|
360
|
+
_findAdditionalStorages(manager) {
|
|
361
|
+
const storages = []
|
|
362
|
+
const mainStorage = manager.storage
|
|
363
|
+
|
|
364
|
+
// Look for properties that look like storages
|
|
365
|
+
for (const key of Object.keys(manager)) {
|
|
366
|
+
// Skip private and known non-storage properties
|
|
367
|
+
if (key.startsWith('_') || key === 'storage') continue
|
|
368
|
+
|
|
369
|
+
const value = manager[key]
|
|
370
|
+
if (!value || typeof value !== 'object') continue
|
|
371
|
+
|
|
372
|
+
// Check if it looks like a storage (has list, get, create methods or endpoint)
|
|
373
|
+
const isStorage = (
|
|
374
|
+
(typeof value.list === 'function' && typeof value.get === 'function') ||
|
|
375
|
+
(value.endpoint && typeof value.fetch === 'function')
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if (isStorage && value !== mainStorage) {
|
|
379
|
+
storages.push({ name: key, storage: value })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return storages
|
|
384
|
+
}
|
|
385
|
+
|
|
314
386
|
/**
|
|
315
387
|
* Get cached items from a manager
|
|
316
388
|
* @param {EntityManager} manager - Manager instance
|
|
@@ -418,4 +490,34 @@ export class EntitiesCollector extends Collector {
|
|
|
418
490
|
}
|
|
419
491
|
}
|
|
420
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Test fetch data from a specific storage of an entity
|
|
496
|
+
* @param {string} entityName - Entity name
|
|
497
|
+
* @param {string} storageName - Storage property name ('storage' for primary)
|
|
498
|
+
* @returns {Promise<{success: boolean, count?: number, error?: string, status?: number}>}
|
|
499
|
+
*/
|
|
500
|
+
async testStorageFetch(entityName, storageName) {
|
|
501
|
+
if (!this._orchestrator) {
|
|
502
|
+
return { success: false, error: 'No orchestrator' }
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const manager = this._orchestrator.get(entityName)
|
|
506
|
+
const storage = storageName === 'storage' ? manager.storage : manager[storageName]
|
|
507
|
+
if (!storage) {
|
|
508
|
+
return { success: false, error: `Storage '${storageName}' not found` }
|
|
509
|
+
}
|
|
510
|
+
const result = await storage.list({ page: 1, page_size: 1 })
|
|
511
|
+
return {
|
|
512
|
+
success: true,
|
|
513
|
+
count: result.total ?? result.items?.length ?? 0
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
error: e.message,
|
|
519
|
+
status: e.status
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
421
523
|
}
|
|
@@ -21,6 +21,8 @@ const expandedEntities = ref(new Set())
|
|
|
21
21
|
const loadingCache = ref(new Set())
|
|
22
22
|
const testingFetch = ref(new Set())
|
|
23
23
|
const testResults = ref(new Map()) // entityName -> { success, count, error, status }
|
|
24
|
+
const testingStorages = ref(new Set()) // "entityName:storageName"
|
|
25
|
+
const storageTestResults = ref(new Map()) // "entityName:storageName" -> { success, count, error, status }
|
|
24
26
|
|
|
25
27
|
function toggleExpand(name) {
|
|
26
28
|
if (expandedEntities.value.has(name)) {
|
|
@@ -82,6 +84,31 @@ async function testFetch(entityName) {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
function isTestingStorage(entityName, storageName) {
|
|
88
|
+
return testingStorages.value.has(`${entityName}:${storageName}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getStorageTestResult(entityName, storageName) {
|
|
92
|
+
return storageTestResults.value.get(`${entityName}:${storageName}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function testStorageFetch(entityName, storageName) {
|
|
96
|
+
const key = `${entityName}:${storageName}`
|
|
97
|
+
if (testingStorages.value.has(key)) return
|
|
98
|
+
testingStorages.value.add(key)
|
|
99
|
+
testingStorages.value = new Set(testingStorages.value)
|
|
100
|
+
storageTestResults.value.delete(key)
|
|
101
|
+
storageTestResults.value = new Map(storageTestResults.value)
|
|
102
|
+
try {
|
|
103
|
+
const result = await props.collector.testStorageFetch(entityName, storageName)
|
|
104
|
+
storageTestResults.value.set(key, result)
|
|
105
|
+
storageTestResults.value = new Map(storageTestResults.value)
|
|
106
|
+
} finally {
|
|
107
|
+
testingStorages.value.delete(key)
|
|
108
|
+
testingStorages.value = new Set(testingStorages.value)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
function getCapabilityIcon(cap) {
|
|
86
113
|
const icons = {
|
|
87
114
|
supportsTotal: 'pi-hashtag',
|
|
@@ -164,24 +191,102 @@ function getCapabilityLabel(cap) {
|
|
|
164
191
|
|
|
165
192
|
<!-- Expanded details -->
|
|
166
193
|
<div v-else class="entity-details">
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<span
|
|
176
|
-
v-for="(enabled, cap) in entity.storage.capabilities"
|
|
177
|
-
:key="cap"
|
|
178
|
-
class="entity-cap"
|
|
179
|
-
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
180
|
-
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
181
|
-
>
|
|
182
|
-
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
194
|
+
<!-- Storages section -->
|
|
195
|
+
<div class="entity-storages-section">
|
|
196
|
+
<div class="entity-storages-header">
|
|
197
|
+
<i class="pi pi-database" />
|
|
198
|
+
<span>Storages</span>
|
|
199
|
+
<span v-if="entity.multiStorage?.enabled" class="entity-multi-badge" title="Multi-storage routing">
|
|
200
|
+
<i class="pi pi-sitemap" />
|
|
201
|
+
{{ 1 + entity.multiStorage.storages.length }}
|
|
183
202
|
</span>
|
|
184
203
|
</div>
|
|
204
|
+
|
|
205
|
+
<!-- Primary storage -->
|
|
206
|
+
<div class="entity-storage-card">
|
|
207
|
+
<div class="entity-storage-header">
|
|
208
|
+
<span class="entity-storage-name">storage</span>
|
|
209
|
+
<span class="entity-storage-type">{{ entity.storage.type }}</span>
|
|
210
|
+
<span v-if="entity.storage.hasNormalize || entity.storage.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
211
|
+
<i class="pi pi-arrows-h" />
|
|
212
|
+
</span>
|
|
213
|
+
<button
|
|
214
|
+
class="entity-storage-fetch-btn"
|
|
215
|
+
:disabled="isTestingStorage(entity.name, 'storage')"
|
|
216
|
+
@click.stop="testStorageFetch(entity.name, 'storage')"
|
|
217
|
+
title="Test fetch on this storage"
|
|
218
|
+
>
|
|
219
|
+
<i :class="['pi', isTestingStorage(entity.name, 'storage') ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div v-if="entity.storage.endpoint" class="entity-storage-endpoint">
|
|
223
|
+
{{ entity.storage.endpoint }}
|
|
224
|
+
</div>
|
|
225
|
+
<div class="entity-storage-row">
|
|
226
|
+
<div v-if="entity.storage.capabilities && Object.keys(entity.storage.capabilities).length > 0" class="entity-storage-caps">
|
|
227
|
+
<span
|
|
228
|
+
v-for="(enabled, cap) in entity.storage.capabilities"
|
|
229
|
+
:key="cap"
|
|
230
|
+
class="entity-cap"
|
|
231
|
+
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
232
|
+
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
233
|
+
>
|
|
234
|
+
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<span v-if="getStorageTestResult(entity.name, 'storage')" class="entity-storage-test-result" :class="getStorageTestResult(entity.name, 'storage').success ? 'test-success' : 'test-error'">
|
|
238
|
+
<template v-if="getStorageTestResult(entity.name, 'storage').success">
|
|
239
|
+
<i class="pi pi-check-circle" /> {{ getStorageTestResult(entity.name, 'storage').count }}
|
|
240
|
+
</template>
|
|
241
|
+
<template v-else>
|
|
242
|
+
<i class="pi pi-times-circle" /> {{ getStorageTestResult(entity.name, 'storage').status || 'ERR' }}
|
|
243
|
+
</template>
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Additional storages -->
|
|
249
|
+
<div v-for="s in entity.multiStorage?.storages || []" :key="s.name" class="entity-storage-card entity-storage-alt">
|
|
250
|
+
<div class="entity-storage-header">
|
|
251
|
+
<span class="entity-storage-name">{{ s.name }}</span>
|
|
252
|
+
<span class="entity-storage-type">{{ s.type }}</span>
|
|
253
|
+
<span v-if="s.hasNormalize || s.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
254
|
+
<i class="pi pi-arrows-h" />
|
|
255
|
+
</span>
|
|
256
|
+
<button
|
|
257
|
+
class="entity-storage-fetch-btn"
|
|
258
|
+
:disabled="isTestingStorage(entity.name, s.name)"
|
|
259
|
+
@click.stop="testStorageFetch(entity.name, s.name)"
|
|
260
|
+
title="Test fetch on this storage"
|
|
261
|
+
>
|
|
262
|
+
<i :class="['pi', isTestingStorage(entity.name, s.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
<div v-if="s.endpoint" class="entity-storage-endpoint">
|
|
266
|
+
{{ s.endpoint }}
|
|
267
|
+
</div>
|
|
268
|
+
<div class="entity-storage-row">
|
|
269
|
+
<div v-if="s.capabilities && Object.keys(s.capabilities).length > 0" class="entity-storage-caps">
|
|
270
|
+
<span
|
|
271
|
+
v-for="(enabled, cap) in s.capabilities"
|
|
272
|
+
:key="cap"
|
|
273
|
+
class="entity-cap"
|
|
274
|
+
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
275
|
+
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
276
|
+
>
|
|
277
|
+
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
<span v-if="getStorageTestResult(entity.name, s.name)" class="entity-storage-test-result" :class="getStorageTestResult(entity.name, s.name).success ? 'test-success' : 'test-error'">
|
|
281
|
+
<template v-if="getStorageTestResult(entity.name, s.name).success">
|
|
282
|
+
<i class="pi pi-check-circle" /> {{ getStorageTestResult(entity.name, s.name).count }}
|
|
283
|
+
</template>
|
|
284
|
+
<template v-else>
|
|
285
|
+
<i class="pi pi-times-circle" /> {{ getStorageTestResult(entity.name, s.name).status || 'ERR' }}
|
|
286
|
+
</template>
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
185
290
|
</div>
|
|
186
291
|
<div v-if="entity.cache.enabled" class="entity-row">
|
|
187
292
|
<span class="entity-key">Cache:</span>
|
|
@@ -213,29 +318,6 @@ function getCapabilityLabel(cap) {
|
|
|
213
318
|
<span class="entity-key">Cache:</span>
|
|
214
319
|
<span class="entity-value entity-cache-na">Disabled</span>
|
|
215
320
|
</div>
|
|
216
|
-
<!-- Test Fetch row - always visible for testing auth protection -->
|
|
217
|
-
<div class="entity-row">
|
|
218
|
-
<span class="entity-key">Test:</span>
|
|
219
|
-
<span v-if="getTestResult(entity.name)" class="entity-test-result" :class="getTestResult(entity.name).success ? 'test-success' : 'test-error'">
|
|
220
|
-
<template v-if="getTestResult(entity.name).success">
|
|
221
|
-
<i class="pi pi-check-circle" />
|
|
222
|
-
{{ getTestResult(entity.name).count }} items
|
|
223
|
-
</template>
|
|
224
|
-
<template v-else>
|
|
225
|
-
<i class="pi pi-times-circle" />
|
|
226
|
-
{{ getTestResult(entity.name).status || 'ERR' }}: {{ getTestResult(entity.name).error }}
|
|
227
|
-
</template>
|
|
228
|
-
</span>
|
|
229
|
-
<span v-else class="entity-value entity-test-na">-</span>
|
|
230
|
-
<button
|
|
231
|
-
class="entity-test-btn"
|
|
232
|
-
:disabled="isTesting(entity.name)"
|
|
233
|
-
@click.stop="testFetch(entity.name)"
|
|
234
|
-
>
|
|
235
|
-
<i :class="['pi', isTesting(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
236
|
-
{{ isTesting(entity.name) ? 'Testing...' : 'Fetch' }}
|
|
237
|
-
</button>
|
|
238
|
-
</div>
|
|
239
321
|
<div class="entity-row">
|
|
240
322
|
<span class="entity-key">Fields:</span>
|
|
241
323
|
<span class="entity-value">{{ entity.fields.count }} fields</span>
|
|
@@ -580,6 +662,120 @@ function getCapabilityLabel(cap) {
|
|
|
580
662
|
background: #3f3f46;
|
|
581
663
|
border-radius: 2px;
|
|
582
664
|
}
|
|
665
|
+
.entity-normalize-badge {
|
|
666
|
+
display: inline-flex;
|
|
667
|
+
align-items: center;
|
|
668
|
+
justify-content: center;
|
|
669
|
+
width: 18px;
|
|
670
|
+
height: 18px;
|
|
671
|
+
background: rgba(139, 92, 246, 0.2);
|
|
672
|
+
color: #a78bfa;
|
|
673
|
+
border-radius: 3px;
|
|
674
|
+
font-size: 10px;
|
|
675
|
+
}
|
|
676
|
+
.entity-multi-badge {
|
|
677
|
+
display: inline-flex;
|
|
678
|
+
align-items: center;
|
|
679
|
+
gap: 3px;
|
|
680
|
+
padding: 2px 6px;
|
|
681
|
+
background: rgba(59, 130, 246, 0.2);
|
|
682
|
+
color: #60a5fa;
|
|
683
|
+
border-radius: 3px;
|
|
684
|
+
font-size: 10px;
|
|
685
|
+
font-weight: 600;
|
|
686
|
+
}
|
|
687
|
+
/* Storages section */
|
|
688
|
+
.entity-storages-section {
|
|
689
|
+
margin-bottom: 8px;
|
|
690
|
+
}
|
|
691
|
+
.entity-storages-header {
|
|
692
|
+
display: flex;
|
|
693
|
+
align-items: center;
|
|
694
|
+
gap: 6px;
|
|
695
|
+
color: #a1a1aa;
|
|
696
|
+
font-size: 11px;
|
|
697
|
+
margin-bottom: 6px;
|
|
698
|
+
}
|
|
699
|
+
.entity-storages-header .pi {
|
|
700
|
+
font-size: 10px;
|
|
701
|
+
}
|
|
702
|
+
.entity-storage-card {
|
|
703
|
+
background: #1f1f23;
|
|
704
|
+
border-radius: 4px;
|
|
705
|
+
padding: 6px 8px;
|
|
706
|
+
margin-bottom: 4px;
|
|
707
|
+
border-left: 2px solid #3b82f6;
|
|
708
|
+
}
|
|
709
|
+
.entity-storage-alt {
|
|
710
|
+
border-left-color: #8b5cf6;
|
|
711
|
+
}
|
|
712
|
+
.entity-storage-header {
|
|
713
|
+
display: flex;
|
|
714
|
+
align-items: center;
|
|
715
|
+
gap: 6px;
|
|
716
|
+
}
|
|
717
|
+
.entity-storage-name {
|
|
718
|
+
color: #60a5fa;
|
|
719
|
+
font-weight: 500;
|
|
720
|
+
font-family: 'JetBrains Mono', monospace;
|
|
721
|
+
font-size: 11px;
|
|
722
|
+
}
|
|
723
|
+
.entity-storage-alt .entity-storage-name {
|
|
724
|
+
color: #a78bfa;
|
|
725
|
+
}
|
|
726
|
+
.entity-storage-type {
|
|
727
|
+
color: #a1a1aa;
|
|
728
|
+
font-size: 10px;
|
|
729
|
+
}
|
|
730
|
+
.entity-storage-endpoint {
|
|
731
|
+
color: #71717a;
|
|
732
|
+
font-family: 'JetBrains Mono', monospace;
|
|
733
|
+
font-size: 10px;
|
|
734
|
+
padding: 2px 0;
|
|
735
|
+
margin-top: 2px;
|
|
736
|
+
}
|
|
737
|
+
.entity-storage-row {
|
|
738
|
+
display: flex;
|
|
739
|
+
align-items: center;
|
|
740
|
+
gap: 8px;
|
|
741
|
+
margin-top: 4px;
|
|
742
|
+
}
|
|
743
|
+
.entity-storage-caps {
|
|
744
|
+
display: flex;
|
|
745
|
+
gap: 4px;
|
|
746
|
+
flex-wrap: wrap;
|
|
747
|
+
}
|
|
748
|
+
.entity-storage-fetch-btn {
|
|
749
|
+
display: inline-flex;
|
|
750
|
+
align-items: center;
|
|
751
|
+
justify-content: center;
|
|
752
|
+
width: 22px;
|
|
753
|
+
height: 22px;
|
|
754
|
+
padding: 0;
|
|
755
|
+
margin-left: auto;
|
|
756
|
+
background: #3b82f6;
|
|
757
|
+
border: none;
|
|
758
|
+
border-radius: 3px;
|
|
759
|
+
color: #fff;
|
|
760
|
+
cursor: pointer;
|
|
761
|
+
font-size: 10px;
|
|
762
|
+
}
|
|
763
|
+
.entity-storage-fetch-btn:hover {
|
|
764
|
+
background: #2563eb;
|
|
765
|
+
}
|
|
766
|
+
.entity-storage-fetch-btn:disabled {
|
|
767
|
+
opacity: 0.5;
|
|
768
|
+
cursor: not-allowed;
|
|
769
|
+
}
|
|
770
|
+
.entity-storage-test-result {
|
|
771
|
+
display: inline-flex;
|
|
772
|
+
align-items: center;
|
|
773
|
+
gap: 3px;
|
|
774
|
+
padding: 2px 6px;
|
|
775
|
+
border-radius: 3px;
|
|
776
|
+
font-size: 10px;
|
|
777
|
+
margin-left: auto;
|
|
778
|
+
}
|
|
583
779
|
.entity-capabilities {
|
|
584
780
|
display: flex;
|
|
585
781
|
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, path } = this.resolveStorage('list', context)
|
|
920
|
+
if (!storage) {
|
|
864
921
|
throw new Error(`[EntityManager:${this.name}] list() not implemented`)
|
|
865
922
|
}
|
|
866
923
|
|
|
@@ -892,8 +949,22 @@ export class EntityManager {
|
|
|
892
949
|
|
|
893
950
|
if (!_internal) this._stats.cacheMisses++
|
|
894
951
|
|
|
895
|
-
// 2. Fetch from API
|
|
896
|
-
|
|
952
|
+
// 2. Fetch from API
|
|
953
|
+
let response
|
|
954
|
+
if (path && storage.request) {
|
|
955
|
+
// Use request() with path for multi-storage routing
|
|
956
|
+
// Build query params for GET request
|
|
957
|
+
const apiResponse = await storage.request('GET', path, { params: queryParams })
|
|
958
|
+
// Normalize response: handle both { data: [...], pagination: {...} } and { items, total }
|
|
959
|
+
const data = apiResponse.data ?? apiResponse
|
|
960
|
+
response = {
|
|
961
|
+
items: Array.isArray(data) ? data : (data.items || data.data || []),
|
|
962
|
+
total: data.total ?? data.pagination?.total ?? (Array.isArray(data) ? data.length : 0)
|
|
963
|
+
}
|
|
964
|
+
} else {
|
|
965
|
+
// Standard storage.list() (normalizes response to { items, total })
|
|
966
|
+
response = await storage.list(queryParams)
|
|
967
|
+
}
|
|
897
968
|
const items = response.items || []
|
|
898
969
|
const total = response.total ?? items.length
|
|
899
970
|
|
|
@@ -937,9 +1008,11 @@ export class EntityManager {
|
|
|
937
1008
|
* Otherwise fetches from storage.
|
|
938
1009
|
*
|
|
939
1010
|
* @param {string|number} id
|
|
1011
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
940
1012
|
* @returns {Promise<object>}
|
|
941
1013
|
*/
|
|
942
|
-
async get(id) {
|
|
1014
|
+
async get(id, context) {
|
|
1015
|
+
const { storage, path } = this.resolveStorage('get', context)
|
|
943
1016
|
this._stats.get++
|
|
944
1017
|
|
|
945
1018
|
// Try cache first if valid and complete
|
|
@@ -954,8 +1027,13 @@ export class EntityManager {
|
|
|
954
1027
|
|
|
955
1028
|
// Fallback to storage
|
|
956
1029
|
this._stats.cacheMisses++
|
|
957
|
-
if (
|
|
958
|
-
|
|
1030
|
+
if (storage) {
|
|
1031
|
+
// Use request() with path for multi-storage routing, otherwise use get()
|
|
1032
|
+
if (path && storage.request) {
|
|
1033
|
+
const response = await storage.request('GET', `${path}/${id}`)
|
|
1034
|
+
return response.data ?? response
|
|
1035
|
+
}
|
|
1036
|
+
return storage.get(id)
|
|
959
1037
|
}
|
|
960
1038
|
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
961
1039
|
}
|
|
@@ -1006,17 +1084,25 @@ export class EntityManager {
|
|
|
1006
1084
|
* - postsave: After successful storage.create(), for side effects
|
|
1007
1085
|
*
|
|
1008
1086
|
* @param {object} data
|
|
1087
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1009
1088
|
* @returns {Promise<object>} - The created entity
|
|
1010
1089
|
*/
|
|
1011
|
-
async create(data) {
|
|
1090
|
+
async create(data, context) {
|
|
1091
|
+
const { storage, path } = this.resolveStorage('create', context)
|
|
1012
1092
|
this._stats.create++
|
|
1013
|
-
if (
|
|
1093
|
+
if (storage) {
|
|
1014
1094
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1015
1095
|
const presaveContext = this._buildPresaveContext(data, true)
|
|
1016
1096
|
await this._invokeHook('presave', presaveContext)
|
|
1017
1097
|
|
|
1018
|
-
// Use
|
|
1019
|
-
|
|
1098
|
+
// Use request() with path for multi-storage routing, otherwise use create()
|
|
1099
|
+
let result
|
|
1100
|
+
if (path && storage.request) {
|
|
1101
|
+
const response = await storage.request('POST', path, { data: presaveContext.record })
|
|
1102
|
+
result = response.data ?? response
|
|
1103
|
+
} else {
|
|
1104
|
+
result = await storage.create(presaveContext.record)
|
|
1105
|
+
}
|
|
1020
1106
|
this.invalidateCache()
|
|
1021
1107
|
|
|
1022
1108
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1043,17 +1129,25 @@ export class EntityManager {
|
|
|
1043
1129
|
*
|
|
1044
1130
|
* @param {string|number} id
|
|
1045
1131
|
* @param {object} data
|
|
1132
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1046
1133
|
* @returns {Promise<object>}
|
|
1047
1134
|
*/
|
|
1048
|
-
async update(id, data) {
|
|
1135
|
+
async update(id, data, context) {
|
|
1136
|
+
const { storage, path } = this.resolveStorage('update', context)
|
|
1049
1137
|
this._stats.update++
|
|
1050
|
-
if (
|
|
1138
|
+
if (storage) {
|
|
1051
1139
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1052
1140
|
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
1053
1141
|
await this._invokeHook('presave', presaveContext)
|
|
1054
1142
|
|
|
1055
|
-
// Use
|
|
1056
|
-
|
|
1143
|
+
// Use request() with path for multi-storage routing, otherwise use update()
|
|
1144
|
+
let result
|
|
1145
|
+
if (path && storage.request) {
|
|
1146
|
+
const response = await storage.request('PUT', `${path}/${id}`, { data: presaveContext.record })
|
|
1147
|
+
result = response.data ?? response
|
|
1148
|
+
} else {
|
|
1149
|
+
result = await storage.update(id, presaveContext.record)
|
|
1150
|
+
}
|
|
1057
1151
|
this.invalidateCache()
|
|
1058
1152
|
|
|
1059
1153
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1080,17 +1174,25 @@ export class EntityManager {
|
|
|
1080
1174
|
*
|
|
1081
1175
|
* @param {string|number} id
|
|
1082
1176
|
* @param {object} data
|
|
1177
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1083
1178
|
* @returns {Promise<object>}
|
|
1084
1179
|
*/
|
|
1085
|
-
async patch(id, data) {
|
|
1180
|
+
async patch(id, data, context) {
|
|
1181
|
+
const { storage, path } = this.resolveStorage('patch', context)
|
|
1086
1182
|
this._stats.update++ // patch counts as update
|
|
1087
|
-
if (
|
|
1183
|
+
if (storage) {
|
|
1088
1184
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1089
1185
|
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
1090
1186
|
await this._invokeHook('presave', presaveContext)
|
|
1091
1187
|
|
|
1092
|
-
// Use
|
|
1093
|
-
|
|
1188
|
+
// Use request() with path for multi-storage routing, otherwise use patch()
|
|
1189
|
+
let result
|
|
1190
|
+
if (path && storage.request) {
|
|
1191
|
+
const response = await storage.request('PATCH', `${path}/${id}`, { data: presaveContext.record })
|
|
1192
|
+
result = response.data ?? response
|
|
1193
|
+
} else {
|
|
1194
|
+
result = await storage.patch(id, presaveContext.record)
|
|
1195
|
+
}
|
|
1094
1196
|
this.invalidateCache()
|
|
1095
1197
|
|
|
1096
1198
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1115,16 +1217,24 @@ export class EntityManager {
|
|
|
1115
1217
|
* - predelete: Before storage.delete(), can throw to abort (for cascade checks)
|
|
1116
1218
|
*
|
|
1117
1219
|
* @param {string|number} id
|
|
1220
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1118
1221
|
* @returns {Promise<void>}
|
|
1119
1222
|
*/
|
|
1120
|
-
async delete(id) {
|
|
1223
|
+
async delete(id, context) {
|
|
1224
|
+
const { storage, path } = this.resolveStorage('delete', context)
|
|
1121
1225
|
this._stats.delete++
|
|
1122
|
-
if (
|
|
1226
|
+
if (storage) {
|
|
1123
1227
|
// Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
|
|
1124
1228
|
const predeleteContext = this._buildPredeleteContext(id)
|
|
1125
1229
|
await this._invokeHook('predelete', predeleteContext)
|
|
1126
1230
|
|
|
1127
|
-
|
|
1231
|
+
// Use request() with path for multi-storage routing, otherwise use delete()
|
|
1232
|
+
let result
|
|
1233
|
+
if (path && storage.request) {
|
|
1234
|
+
result = await storage.request('DELETE', `${path}/${id}`)
|
|
1235
|
+
} else {
|
|
1236
|
+
result = await storage.delete(id)
|
|
1237
|
+
}
|
|
1128
1238
|
this.invalidateCache()
|
|
1129
1239
|
this._emitSignal('deleted', {
|
|
1130
1240
|
manager: this.name,
|
|
@@ -1573,25 +1683,24 @@ export class EntityManager {
|
|
|
1573
1683
|
* @param {object} params - Query params (search, filters, sort_by, sort_order, page, page_size)
|
|
1574
1684
|
* @param {object} [options] - Query options
|
|
1575
1685
|
* @param {object} [options.context] - Context info (module, form, field, scope, bypassPermissions, reason)
|
|
1686
|
+
* @param {object} [options.routingContext] - Multi-storage routing context with parentChain
|
|
1576
1687
|
* @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
|
|
1577
1688
|
*/
|
|
1578
1689
|
async query(params = {}, options = {}) {
|
|
1579
|
-
const { context = {} } = options
|
|
1690
|
+
const { context = {}, routingContext = null } = options
|
|
1580
1691
|
|
|
1581
1692
|
// Ensure cache is filled (via list)
|
|
1582
1693
|
if (!this._cache.valid && this.isCacheEnabled) {
|
|
1583
|
-
await this.list({ page_size: this.effectiveThreshold })
|
|
1694
|
+
await this.list({ page_size: this.effectiveThreshold }, routingContext)
|
|
1584
1695
|
}
|
|
1585
1696
|
|
|
1586
1697
|
let result
|
|
1587
1698
|
|
|
1588
1699
|
// If overflow or cache disabled, use API for accurate filtered results
|
|
1589
1700
|
if (this.overflow || !this.isCacheEnabled) {
|
|
1590
|
-
|
|
1591
|
-
result = await this.list(params)
|
|
1701
|
+
result = await this.list(params, routingContext)
|
|
1592
1702
|
} else {
|
|
1593
1703
|
// Full cache available - filter locally
|
|
1594
|
-
console.log('[cache] Using local cache for entity:', this.name)
|
|
1595
1704
|
const filtered = this._filterLocally(this._cache.items, params)
|
|
1596
1705
|
result = { ...filtered, fromCache: true }
|
|
1597
1706
|
}
|
|
@@ -20,6 +20,24 @@ import { IStorage } from './IStorage.js'
|
|
|
20
20
|
* endpoint: '/users',
|
|
21
21
|
* getClient: () => inject('apiClient')
|
|
22
22
|
* })
|
|
23
|
+
*
|
|
24
|
+
* // With data normalization (different API format)
|
|
25
|
+
* const storage = new ApiStorage({
|
|
26
|
+
* endpoint: '/api/projects/:id/tasks',
|
|
27
|
+
* client: apiClient,
|
|
28
|
+
* // API → internal format
|
|
29
|
+
* normalize: (apiData) => ({
|
|
30
|
+
* id: apiData.task_id,
|
|
31
|
+
* title: apiData.name,
|
|
32
|
+
* status: apiData.state
|
|
33
|
+
* }),
|
|
34
|
+
* // Internal → API format
|
|
35
|
+
* denormalize: (data) => ({
|
|
36
|
+
* task_id: data.id,
|
|
37
|
+
* name: data.title,
|
|
38
|
+
* state: data.status
|
|
39
|
+
* })
|
|
40
|
+
* })
|
|
23
41
|
* ```
|
|
24
42
|
*/
|
|
25
43
|
export class ApiStorage extends IStorage {
|
|
@@ -55,7 +73,15 @@ export class ApiStorage extends IStorage {
|
|
|
55
73
|
getClient = null,
|
|
56
74
|
// Response format configuration
|
|
57
75
|
responseItemsKey = 'items',
|
|
58
|
-
responseTotalKey = 'total'
|
|
76
|
+
responseTotalKey = 'total',
|
|
77
|
+
// WIP: Parameter mapping for filters { clientName: apiName }
|
|
78
|
+
// Transforms filter param names before sending to API
|
|
79
|
+
paramMapping = {},
|
|
80
|
+
// Data normalization
|
|
81
|
+
// normalize: (apiData) => internalData - transform API response
|
|
82
|
+
// denormalize: (internalData) => apiData - transform before sending
|
|
83
|
+
normalize = null,
|
|
84
|
+
denormalize = null
|
|
59
85
|
} = options
|
|
60
86
|
|
|
61
87
|
this.endpoint = endpoint
|
|
@@ -63,6 +89,49 @@ export class ApiStorage extends IStorage {
|
|
|
63
89
|
this._getClient = getClient
|
|
64
90
|
this.responseItemsKey = responseItemsKey
|
|
65
91
|
this.responseTotalKey = responseTotalKey
|
|
92
|
+
this.paramMapping = paramMapping
|
|
93
|
+
this._normalize = normalize
|
|
94
|
+
this._denormalize = denormalize
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* WIP: Apply parameter mapping to transform filter names
|
|
99
|
+
* @param {object} params - Original params
|
|
100
|
+
* @returns {object} - Params with mapped names
|
|
101
|
+
*/
|
|
102
|
+
_applyParamMapping(params) {
|
|
103
|
+
if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
|
|
104
|
+
return params
|
|
105
|
+
}
|
|
106
|
+
const mapped = {}
|
|
107
|
+
for (const [key, value] of Object.entries(params)) {
|
|
108
|
+
const mappedKey = this.paramMapping[key] || key
|
|
109
|
+
mapped[mappedKey] = value
|
|
110
|
+
}
|
|
111
|
+
return mapped
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalize API response data to internal format
|
|
116
|
+
* @param {object|Array} data - API response data
|
|
117
|
+
* @returns {object|Array} - Normalized data
|
|
118
|
+
*/
|
|
119
|
+
_normalizeData(data) {
|
|
120
|
+
if (!this._normalize) return data
|
|
121
|
+
if (Array.isArray(data)) {
|
|
122
|
+
return data.map(item => this._normalize(item))
|
|
123
|
+
}
|
|
124
|
+
return this._normalize(data)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Denormalize internal data to API format
|
|
129
|
+
* @param {object} data - Internal data
|
|
130
|
+
* @returns {object} - API format data
|
|
131
|
+
*/
|
|
132
|
+
_denormalizeData(data) {
|
|
133
|
+
if (!this._denormalize) return data
|
|
134
|
+
return this._denormalize(data)
|
|
66
135
|
}
|
|
67
136
|
|
|
68
137
|
get client() {
|
|
@@ -88,13 +157,18 @@ export class ApiStorage extends IStorage {
|
|
|
88
157
|
*/
|
|
89
158
|
async list(params = {}) {
|
|
90
159
|
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
160
|
+
|
|
161
|
+
// WIP: Apply param mapping to filters
|
|
162
|
+
const mappedFilters = this._applyParamMapping(filters)
|
|
163
|
+
|
|
91
164
|
const response = await this.client.get(this.endpoint, {
|
|
92
|
-
params: { page, page_size, sort_by, sort_order, ...
|
|
165
|
+
params: { page, page_size, sort_by, sort_order, ...mappedFilters }
|
|
93
166
|
})
|
|
94
167
|
|
|
95
168
|
const data = response.data
|
|
169
|
+
const items = data[this.responseItemsKey] || data.items || data
|
|
96
170
|
return {
|
|
97
|
-
items:
|
|
171
|
+
items: this._normalizeData(items),
|
|
98
172
|
total: data[this.responseTotalKey] || data.total || (Array.isArray(data) ? data.length : 0)
|
|
99
173
|
}
|
|
100
174
|
}
|
|
@@ -106,7 +180,7 @@ export class ApiStorage extends IStorage {
|
|
|
106
180
|
*/
|
|
107
181
|
async get(id) {
|
|
108
182
|
const response = await this.client.get(`${this.endpoint}/${id}`)
|
|
109
|
-
return response.data
|
|
183
|
+
return this._normalizeData(response.data)
|
|
110
184
|
}
|
|
111
185
|
|
|
112
186
|
/**
|
|
@@ -115,8 +189,9 @@ export class ApiStorage extends IStorage {
|
|
|
115
189
|
* @returns {Promise<object>}
|
|
116
190
|
*/
|
|
117
191
|
async create(data) {
|
|
118
|
-
const
|
|
119
|
-
|
|
192
|
+
const apiData = this._denormalizeData(data)
|
|
193
|
+
const response = await this.client.post(this.endpoint, apiData)
|
|
194
|
+
return this._normalizeData(response.data)
|
|
120
195
|
}
|
|
121
196
|
|
|
122
197
|
/**
|
|
@@ -126,8 +201,9 @@ export class ApiStorage extends IStorage {
|
|
|
126
201
|
* @returns {Promise<object>}
|
|
127
202
|
*/
|
|
128
203
|
async update(id, data) {
|
|
129
|
-
const
|
|
130
|
-
|
|
204
|
+
const apiData = this._denormalizeData(data)
|
|
205
|
+
const response = await this.client.put(`${this.endpoint}/${id}`, apiData)
|
|
206
|
+
return this._normalizeData(response.data)
|
|
131
207
|
}
|
|
132
208
|
|
|
133
209
|
/**
|
|
@@ -137,8 +213,9 @@ export class ApiStorage extends IStorage {
|
|
|
137
213
|
* @returns {Promise<object>}
|
|
138
214
|
*/
|
|
139
215
|
async patch(id, data) {
|
|
140
|
-
const
|
|
141
|
-
|
|
216
|
+
const apiData = this._denormalizeData(data)
|
|
217
|
+
const response = await this.client.patch(`${this.endpoint}/${id}`, apiData)
|
|
218
|
+
return this._normalizeData(response.data)
|
|
142
219
|
}
|
|
143
220
|
|
|
144
221
|
/**
|
package/src/kernel/Kernel.js
CHANGED
|
@@ -48,6 +48,7 @@ import { createQdadm } from '../plugin.js'
|
|
|
48
48
|
import { initModules, getRoutes, setSectionOrder, alterMenuSections, registry } from '../module/moduleRegistry.js'
|
|
49
49
|
import { createModuleLoader } from './ModuleLoader.js'
|
|
50
50
|
import { createKernelContext } from './KernelContext.js'
|
|
51
|
+
import { ToastBridgeModule } from '../toast/ToastBridgeModule.js'
|
|
51
52
|
import { Orchestrator } from '../orchestrator/Orchestrator.js'
|
|
52
53
|
import { createSignalBus } from './SignalBus.js'
|
|
53
54
|
import { createZoneRegistry } from '../zones/ZoneRegistry.js'
|
|
@@ -98,6 +99,7 @@ export class Kernel {
|
|
|
98
99
|
* @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
|
|
99
100
|
* @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
|
|
100
101
|
* @param {object} options.debugBar - Debug bar config { module: DebugModule, component: QdadmDebugBar, ...options }
|
|
102
|
+
* @param {boolean} options.toast - Enable ToastBridgeModule (default: true). Set to false to disable.
|
|
101
103
|
*/
|
|
102
104
|
constructor(options) {
|
|
103
105
|
// Auto-inject DebugModule if debugBar.module is provided
|
|
@@ -122,6 +124,15 @@ export class Kernel {
|
|
|
122
124
|
options.debug = true
|
|
123
125
|
}
|
|
124
126
|
}
|
|
127
|
+
|
|
128
|
+
// Auto-inject ToastBridgeModule unless toast: false
|
|
129
|
+
// Handles toast:* signals from useSignalToast() composable
|
|
130
|
+
if (options.toast !== false) {
|
|
131
|
+
options.moduleDefs = options.moduleDefs || []
|
|
132
|
+
// Add at beginning for high priority (loads early)
|
|
133
|
+
options.moduleDefs.unshift(new ToastBridgeModule())
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
this.options = options
|
|
126
137
|
this.vueApp = null
|
|
127
138
|
this.router = null
|
|
@@ -569,11 +580,12 @@ export class Kernel {
|
|
|
569
580
|
}
|
|
570
581
|
|
|
571
582
|
// Build home route
|
|
583
|
+
// Note: Must have a name to avoid Vue Router warning when parent (_layout) has a name
|
|
572
584
|
let homeRouteConfig
|
|
573
585
|
if (typeof homeRoute === 'object' && homeRoute.component) {
|
|
574
|
-
homeRouteConfig = { path: '', ...homeRoute }
|
|
586
|
+
homeRouteConfig = { path: '', name: homeRoute.name || '_home', ...homeRoute }
|
|
575
587
|
} else {
|
|
576
|
-
homeRouteConfig = { path: '', redirect: { name: homeRoute || 'home' } }
|
|
588
|
+
homeRouteConfig = { path: '', name: '_home', redirect: { name: homeRoute || 'home' } }
|
|
577
589
|
}
|
|
578
590
|
|
|
579
591
|
// Collect all module routes
|
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({
|