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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.51.4",
3
+ "version": "0.51.6",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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 useListPageBuilder, useBareForm, etc.)
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)
@@ -8,7 +8,7 @@
8
8
  * - FilterBar with search and custom filters
9
9
  * - DataTable with actions column
10
10
  *
11
- * Props come from useListPageBuilder composable
11
+ * Props come from useListPage composable
12
12
  *
13
13
  * Filter types:
14
14
  * - 'select' (default): Standard dropdown
@@ -203,7 +203,7 @@ async function handleLogin() {
203
203
  <label for="qdadm-password">{{ passwordLabel }}</label>
204
204
  <Password
205
205
  v-model="password"
206
- id="qdadm-password"
206
+ inputId="qdadm-password"
207
207
  class="w-full"
208
208
  :feedback="false"
209
209
  toggleMask
@@ -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 { useListPageBuilder, PAGE_SIZE_OPTIONS } from './useListPageBuilder'
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
- 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({
@@ -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
- * useListPageBuilder - Unified procedural builder for CRUD list pages
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 = useListPageBuilder({ entity: 'domains' })
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 useListPageBuilder(config = {}) {
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
- const parentConfig = route.meta?.parent
1084
- if (parentConfig?.foreignKey && parentConfig?.param) {
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
- <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
- </div>
172
- <div v-if="entity.storage.capabilities && Object.keys(entity.storage.capabilities).length > 0" class="entity-row">
173
- <span class="entity-key">Caps:</span>
174
- <div class="entity-capabilities">
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
- if (!this.storage) {
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 (storage normalizes response to { items, total })
896
- const response = await this.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
+ }
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 (this.storage) {
958
- return this.storage.get(id)
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 (this.storage) {
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 potentially modified data from context
1019
- const result = await this.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
+ }
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 (this.storage) {
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 potentially modified data from context
1056
- const result = await this.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
+ }
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 (this.storage) {
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 potentially modified data from context
1093
- 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
+ }
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 (this.storage) {
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
- const result = await this.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
+ }
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
- console.log('[cache] API call for entity:', this.name, '(total > threshold)', 'isCacheEnabled:', this.isCacheEnabled, 'overflow:', this.overflow)
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, ...filters }
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: data[this.responseItemsKey] || data.items || data,
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 response = await this.client.post(this.endpoint, data)
119
- return response.data
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 response = await this.client.put(`${this.endpoint}/${id}`, data)
130
- return response.data
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 response = await this.client.patch(`${this.endpoint}/${id}`, data)
141
- return response.data
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
  /**
@@ -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
@@ -102,7 +102,7 @@ export class FilterQuery {
102
102
  }
103
103
 
104
104
  /**
105
- * Set the parent manager (called by useListPageBuilder for field source)
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 { useListPageBuilder, ListPage, useOrchestrator } from '../../index.js'
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 = useListPageBuilder({ entity: 'roles' })
13
+ const list = useListPage({ entity: 'roles' })
14
14
 
15
15
  // ============ SEARCH ============
16
16
  list.setSearch({