qdadm 0.51.5 → 0.51.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.51.5",
3
+ "version": "0.51.7",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -298,7 +298,11 @@ export function useEntityItemFormPage(config = {}) {
298
298
  responseData = await manager.update(entityId.value, payload)
299
299
  }
300
300
  } else {
301
- responseData = await manager.create(payload)
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({
@@ -323,18 +327,17 @@ export function useEntityItemFormPage(config = {}) {
323
327
  const listRoute = findListRoute()
324
328
  router.push(listRoute)
325
329
  } else if (!isEdit.value) {
326
- // "Create" without close: reset form for new entry, stay on route
327
- data.value = deepClone(initialData)
328
- originalData.value = null
329
- takeSnapshot()
330
- errors.value = {}
331
- submitted.value = false
332
- toast.add({
333
- severity: 'info',
334
- summary: 'Ready',
335
- detail: 'Form reset for new entry',
336
- life: 2000
337
- })
330
+ // "Create" without close: navigate to edit route for the created entity
331
+ const createdId = responseData?.id || responseData?.uuid
332
+ if (createdId) {
333
+ // Build edit route: replace 'create' suffix with ':id/edit'
334
+ const currentRouteName = route.name || ''
335
+ const editRouteName = currentRouteName.replace(/(-create|-new)$/, '-edit')
336
+ router.push({
337
+ name: editRouteName,
338
+ params: { ...route.params, id: createdId }
339
+ })
340
+ }
338
341
  }
339
342
  // Edit mode without close: just stay on page (data already updated)
340
343
 
@@ -186,6 +186,34 @@ export function useListPage(config = {}) {
186
186
  const parentLoading = computed(() => parentPage?.loading.value || false)
187
187
  const parentChain = computed(() => parentPage?.parentChain.value || new Map())
188
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
+
189
217
  // Read config from manager with option overrides
190
218
  const entityName = config.entityName ?? manager.label
191
219
  const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
@@ -1124,9 +1152,10 @@ export function useListPage(config = {}) {
1124
1152
  }
1125
1153
 
1126
1154
  // Use manager.query() for automatic cache handling
1155
+ // Pass entityContext for multi-storage routing
1127
1156
  const response = manager.query
1128
- ? await manager.query(params)
1129
- : await manager.list(params)
1157
+ ? await manager.query(params, { routingContext: entityContext.value })
1158
+ : await manager.list(params, entityContext.value)
1130
1159
 
1131
1160
  // Track if response came from cache
1132
1161
  fromCache.value = response.fromCache || false
@@ -345,7 +345,8 @@ export class EntitiesCollector extends Collector {
345
345
  type: s.storage?.constructor?.storageName || s.storage?.constructor?.name || 'Unknown',
346
346
  endpoint: s.storage?.endpoint || null,
347
347
  hasNormalize: !!(s.storage?._normalize),
348
- hasDenormalize: !!(s.storage?._denormalize)
348
+ hasDenormalize: !!(s.storage?._denormalize),
349
+ capabilities: s.storage?.capabilities || s.storage?.constructor?.capabilities || {}
349
350
  }))
350
351
  }
351
352
  }
@@ -489,4 +490,34 @@ export class EntitiesCollector extends Collector {
489
490
  }
490
491
  }
491
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
+ }
492
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,41 +191,101 @@ function getCapabilityLabel(cap) {
164
191
 
165
192
  <!-- Expanded details -->
166
193
  <div v-else class="entity-details">
167
- <div class="entity-row">
168
- <span class="entity-key">Storage:</span>
169
- <span class="entity-value">{{ entity.storage.type }}</span>
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" />
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 }}
187
202
  </span>
188
203
  </div>
189
- </div>
190
- <div v-if="entity.storage.capabilities && Object.keys(entity.storage.capabilities).length > 0" class="entity-row">
191
- <span class="entity-key">Caps:</span>
192
- <div class="entity-capabilities">
193
- <span
194
- v-for="(enabled, cap) in entity.storage.capabilities"
195
- :key="cap"
196
- class="entity-cap"
197
- :class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
198
- :title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
199
- >
200
- <i :class="['pi', getCapabilityIcon(cap)]" />
201
- </span>
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>
202
289
  </div>
203
290
  </div>
204
291
  <div v-if="entity.cache.enabled" class="entity-row">
@@ -231,29 +318,6 @@ function getCapabilityLabel(cap) {
231
318
  <span class="entity-key">Cache:</span>
232
319
  <span class="entity-value entity-cache-na">Disabled</span>
233
320
  </div>
234
- <!-- Test Fetch row - always visible for testing auth protection -->
235
- <div class="entity-row">
236
- <span class="entity-key">Test:</span>
237
- <span v-if="getTestResult(entity.name)" class="entity-test-result" :class="getTestResult(entity.name).success ? 'test-success' : 'test-error'">
238
- <template v-if="getTestResult(entity.name).success">
239
- <i class="pi pi-check-circle" />
240
- {{ getTestResult(entity.name).count }} items
241
- </template>
242
- <template v-else>
243
- <i class="pi pi-times-circle" />
244
- {{ getTestResult(entity.name).status || 'ERR' }}: {{ getTestResult(entity.name).error }}
245
- </template>
246
- </span>
247
- <span v-else class="entity-value entity-test-na">-</span>
248
- <button
249
- class="entity-test-btn"
250
- :disabled="isTesting(entity.name)"
251
- @click.stop="testFetch(entity.name)"
252
- >
253
- <i :class="['pi', isTesting(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
254
- {{ isTesting(entity.name) ? 'Testing...' : 'Fetch' }}
255
- </button>
256
- </div>
257
321
  <div class="entity-row">
258
322
  <span class="entity-key">Fields:</span>
259
323
  <span class="entity-value">{{ entity.fields.count }} fields</span>
@@ -620,27 +684,97 @@ function getCapabilityLabel(cap) {
620
684
  font-size: 10px;
621
685
  font-weight: 600;
622
686
  }
623
- .entity-multi-storages {
624
- margin-left: 66px;
625
- margin-top: 4px;
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;
626
697
  margin-bottom: 6px;
627
- padding-left: 8px;
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;
628
707
  border-left: 2px solid #3b82f6;
629
708
  }
630
- .entity-multi-storage {
709
+ .entity-storage-alt {
710
+ border-left-color: #8b5cf6;
711
+ }
712
+ .entity-storage-header {
631
713
  display: flex;
632
714
  align-items: center;
633
715
  gap: 6px;
634
- padding: 3px 0;
635
- font-size: 10px;
636
716
  }
637
717
  .entity-storage-name {
638
718
  color: #60a5fa;
639
719
  font-weight: 500;
640
- min-width: 80px;
720
+ font-family: 'JetBrains Mono', monospace;
721
+ font-size: 11px;
722
+ }
723
+ .entity-storage-alt .entity-storage-name {
724
+ color: #a78bfa;
641
725
  }
642
726
  .entity-storage-type {
643
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;
644
778
  }
645
779
  .entity-capabilities {
646
780
  display: flex;
@@ -916,7 +916,7 @@ export class EntityManager {
916
916
  * @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
917
917
  */
918
918
  async list(params = {}, context) {
919
- const { storage } = this.resolveStorage('list', context)
919
+ const { storage, path } = this.resolveStorage('list', context)
920
920
  if (!storage) {
921
921
  throw new Error(`[EntityManager:${this.name}] list() not implemented`)
922
922
  }
@@ -949,8 +949,22 @@ export class EntityManager {
949
949
 
950
950
  if (!_internal) this._stats.cacheMisses++
951
951
 
952
- // 2. Fetch from API (storage normalizes response to { items, total })
953
- const response = await storage.list(queryParams)
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
+ }
954
968
  const items = response.items || []
955
969
  const total = response.total ?? items.length
956
970
 
@@ -998,7 +1012,7 @@ export class EntityManager {
998
1012
  * @returns {Promise<object>}
999
1013
  */
1000
1014
  async get(id, context) {
1001
- const { storage } = this.resolveStorage('get', context)
1015
+ const { storage, path } = this.resolveStorage('get', context)
1002
1016
  this._stats.get++
1003
1017
 
1004
1018
  // Try cache first if valid and complete
@@ -1014,6 +1028,11 @@ export class EntityManager {
1014
1028
  // Fallback to storage
1015
1029
  this._stats.cacheMisses++
1016
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
+ }
1017
1036
  return storage.get(id)
1018
1037
  }
1019
1038
  throw new Error(`[EntityManager:${this.name}] get() not implemented`)
@@ -1069,15 +1088,21 @@ export class EntityManager {
1069
1088
  * @returns {Promise<object>} - The created entity
1070
1089
  */
1071
1090
  async create(data, context) {
1072
- const { storage } = this.resolveStorage('create', context)
1091
+ const { storage, path } = this.resolveStorage('create', context)
1073
1092
  this._stats.create++
1074
1093
  if (storage) {
1075
1094
  // Invoke presave hooks (can modify data or throw to abort)
1076
1095
  const presaveContext = this._buildPresaveContext(data, true)
1077
1096
  await this._invokeHook('presave', presaveContext)
1078
1097
 
1079
- // Use potentially modified data from context
1080
- const result = await storage.create(presaveContext.record)
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
+ }
1081
1106
  this.invalidateCache()
1082
1107
 
1083
1108
  // Invoke postsave hooks (for side effects)
@@ -1108,15 +1133,21 @@ export class EntityManager {
1108
1133
  * @returns {Promise<object>}
1109
1134
  */
1110
1135
  async update(id, data, context) {
1111
- const { storage } = this.resolveStorage('update', context)
1136
+ const { storage, path } = this.resolveStorage('update', context)
1112
1137
  this._stats.update++
1113
1138
  if (storage) {
1114
1139
  // Invoke presave hooks (can modify data or throw to abort)
1115
1140
  const presaveContext = this._buildPresaveContext(data, false, id)
1116
1141
  await this._invokeHook('presave', presaveContext)
1117
1142
 
1118
- // Use potentially modified data from context
1119
- const result = await storage.update(id, presaveContext.record)
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
+ }
1120
1151
  this.invalidateCache()
1121
1152
 
1122
1153
  // Invoke postsave hooks (for side effects)
@@ -1143,17 +1174,25 @@ export class EntityManager {
1143
1174
  *
1144
1175
  * @param {string|number} id
1145
1176
  * @param {object} data
1177
+ * @param {object} [context] - Routing context for multi-storage
1146
1178
  * @returns {Promise<object>}
1147
1179
  */
1148
- async patch(id, data) {
1180
+ async patch(id, data, context) {
1181
+ const { storage, path } = this.resolveStorage('patch', context)
1149
1182
  this._stats.update++ // patch counts as update
1150
- if (this.storage) {
1183
+ if (storage) {
1151
1184
  // Invoke presave hooks (can modify data or throw to abort)
1152
1185
  const presaveContext = this._buildPresaveContext(data, false, id)
1153
1186
  await this._invokeHook('presave', presaveContext)
1154
1187
 
1155
- // Use potentially modified data from context
1156
- const result = await this.storage.patch(id, presaveContext.record)
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
+ }
1157
1196
  this.invalidateCache()
1158
1197
 
1159
1198
  // Invoke postsave hooks (for side effects)
@@ -1182,14 +1221,20 @@ export class EntityManager {
1182
1221
  * @returns {Promise<void>}
1183
1222
  */
1184
1223
  async delete(id, context) {
1185
- const { storage } = this.resolveStorage('delete', context)
1224
+ const { storage, path } = this.resolveStorage('delete', context)
1186
1225
  this._stats.delete++
1187
1226
  if (storage) {
1188
1227
  // Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
1189
1228
  const predeleteContext = this._buildPredeleteContext(id)
1190
1229
  await this._invokeHook('predelete', predeleteContext)
1191
1230
 
1192
- const result = await storage.delete(id)
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
+ }
1193
1238
  this.invalidateCache()
1194
1239
  this._emitSignal('deleted', {
1195
1240
  manager: this.name,
@@ -1638,25 +1683,24 @@ export class EntityManager {
1638
1683
  * @param {object} params - Query params (search, filters, sort_by, sort_order, page, page_size)
1639
1684
  * @param {object} [options] - Query options
1640
1685
  * @param {object} [options.context] - Context info (module, form, field, scope, bypassPermissions, reason)
1686
+ * @param {object} [options.routingContext] - Multi-storage routing context with parentChain
1641
1687
  * @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
1642
1688
  */
1643
1689
  async query(params = {}, options = {}) {
1644
- const { context = {} } = options
1690
+ const { context = {}, routingContext = null } = options
1645
1691
 
1646
1692
  // Ensure cache is filled (via list)
1647
1693
  if (!this._cache.valid && this.isCacheEnabled) {
1648
- await this.list({ page_size: this.effectiveThreshold })
1694
+ await this.list({ page_size: this.effectiveThreshold }, routingContext)
1649
1695
  }
1650
1696
 
1651
1697
  let result
1652
1698
 
1653
1699
  // If overflow or cache disabled, use API for accurate filtered results
1654
1700
  if (this.overflow || !this.isCacheEnabled) {
1655
- console.log('[cache] API call for entity:', this.name, '(total > threshold)', 'isCacheEnabled:', this.isCacheEnabled, 'overflow:', this.overflow)
1656
- result = await this.list(params)
1701
+ result = await this.list(params, routingContext)
1657
1702
  } else {
1658
1703
  // Full cache available - filter locally
1659
- console.log('[cache] Using local cache for entity:', this.name)
1660
1704
  const filtered = this._filterLocally(this._cache.items, params)
1661
1705
  result = { ...filtered, fromCache: true }
1662
1706
  }
@@ -21,16 +21,6 @@ import { IStorage } from './IStorage.js'
21
21
  * getClient: () => inject('apiClient')
22
22
  * })
23
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
24
  * // With data normalization (different API format)
35
25
  * const storage = new ApiStorage({
36
26
  * endpoint: '/api/projects/:id/tasks',
@@ -84,8 +74,8 @@ export class ApiStorage extends IStorage {
84
74
  // Response format configuration
85
75
  responseItemsKey = 'items',
86
76
  responseTotalKey = 'total',
87
- // Parameter mapping: { clientName: apiName }
88
- // Transforms filter and query param names before sending to API
77
+ // WIP: Parameter mapping for filters { clientName: apiName }
78
+ // Transforms filter param names before sending to API
89
79
  paramMapping = {},
90
80
  // Data normalization
91
81
  // normalize: (apiData) => internalData - transform API response
@@ -104,6 +94,23 @@ export class ApiStorage extends IStorage {
104
94
  this._denormalize = denormalize
105
95
  }
106
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
+
107
114
  /**
108
115
  * Normalize API response data to internal format
109
116
  * @param {object|Array} data - API response data
@@ -127,24 +134,6 @@ export class ApiStorage extends IStorage {
127
134
  return this._denormalize(data)
128
135
  }
129
136
 
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
146
- }
147
-
148
137
  get client() {
149
138
  if (this._getClient) {
150
139
  return this._getClient()
@@ -169,7 +158,7 @@ export class ApiStorage extends IStorage {
169
158
  async list(params = {}) {
170
159
  const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
171
160
 
172
- // Apply param mapping to filters
161
+ // WIP: Apply param mapping to filters
173
162
  const mappedFilters = this._applyParamMapping(filters)
174
163
 
175
164
  const response = await this.client.get(this.endpoint, {
@@ -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