qdadm 0.51.3 → 0.51.5

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