qdadm 0.15.1 → 0.17.0

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.
Files changed (66) hide show
  1. package/README.md +153 -1
  2. package/package.json +15 -2
  3. package/src/components/BoolCell.vue +11 -6
  4. package/src/components/forms/FormField.vue +64 -6
  5. package/src/components/forms/FormPage.vue +276 -0
  6. package/src/components/index.js +11 -0
  7. package/src/components/layout/AppLayout.vue +18 -9
  8. package/src/components/layout/BaseLayout.vue +183 -0
  9. package/src/components/layout/DashboardLayout.vue +100 -0
  10. package/src/components/layout/FormLayout.vue +261 -0
  11. package/src/components/layout/ListLayout.vue +334 -0
  12. package/src/components/layout/PageHeader.vue +6 -9
  13. package/src/components/layout/PageNav.vue +15 -0
  14. package/src/components/layout/Zone.vue +165 -0
  15. package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
  16. package/src/components/layout/defaults/DefaultFooter.vue +56 -0
  17. package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
  18. package/src/components/layout/defaults/DefaultHeader.vue +69 -0
  19. package/src/components/layout/defaults/DefaultMenu.vue +197 -0
  20. package/src/components/layout/defaults/DefaultPagination.vue +79 -0
  21. package/src/components/layout/defaults/DefaultTable.vue +130 -0
  22. package/src/components/layout/defaults/DefaultToaster.vue +16 -0
  23. package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
  24. package/src/components/layout/defaults/index.js +17 -0
  25. package/src/composables/index.js +8 -6
  26. package/src/composables/useBreadcrumb.js +9 -5
  27. package/src/composables/useForm.js +135 -0
  28. package/src/composables/useFormPageBuilder.js +1154 -0
  29. package/src/composables/useHooks.js +53 -0
  30. package/src/composables/useLayoutResolver.js +260 -0
  31. package/src/composables/useListPageBuilder.js +336 -52
  32. package/src/composables/useNavContext.js +372 -0
  33. package/src/composables/useNavigation.js +38 -2
  34. package/src/composables/usePageTitle.js +59 -0
  35. package/src/composables/useSignals.js +49 -0
  36. package/src/composables/useZoneRegistry.js +162 -0
  37. package/src/core/bundles.js +406 -0
  38. package/src/core/decorator.js +322 -0
  39. package/src/core/extension.js +386 -0
  40. package/src/core/index.js +28 -0
  41. package/src/entity/EntityManager.js +314 -16
  42. package/src/entity/auth/AuthAdapter.js +125 -0
  43. package/src/entity/auth/PermissiveAdapter.js +64 -0
  44. package/src/entity/auth/index.js +11 -0
  45. package/src/entity/index.js +3 -0
  46. package/src/entity/storage/MockApiStorage.js +349 -0
  47. package/src/entity/storage/SdkStorage.js +478 -0
  48. package/src/entity/storage/index.js +2 -0
  49. package/src/hooks/HookRegistry.js +411 -0
  50. package/src/hooks/index.js +12 -0
  51. package/src/index.js +12 -0
  52. package/src/kernel/Kernel.js +141 -4
  53. package/src/kernel/SignalBus.js +180 -0
  54. package/src/kernel/index.js +7 -0
  55. package/src/module/moduleRegistry.js +124 -6
  56. package/src/orchestrator/Orchestrator.js +73 -1
  57. package/src/plugin.js +5 -0
  58. package/src/zones/ZoneRegistry.js +821 -0
  59. package/src/zones/index.js +16 -0
  60. package/src/zones/zones.js +189 -0
  61. package/src/composables/useEntityTitle.js +0 -121
  62. package/src/composables/useManager.js +0 -20
  63. package/src/composables/usePageBuilder.js +0 -334
  64. package/src/composables/useStatus.js +0 -146
  65. package/src/composables/useSubEditor.js +0 -165
  66. package/src/composables/useTabSync.js +0 -110
@@ -2,6 +2,7 @@ import { ref, computed, watch, onMounted, inject, provide } from 'vue'
2
2
  import { useRouter, useRoute } from 'vue-router'
3
3
  import { useToast } from 'primevue/usetoast'
4
4
  import { useConfirm } from 'primevue/useconfirm'
5
+ import { useHooks } from './useHooks.js'
5
6
 
6
7
  // Cookie utilities for pagination persistence
7
8
  const COOKIE_NAME = 'qdadm_pageSize'
@@ -168,6 +169,9 @@ export function useListPageBuilder(config = {}) {
168
169
  // Entity filters registry (optional, provided by consuming app)
169
170
  const entityFilters = inject('qdadmEntityFilters', {})
170
171
 
172
+ // Get HookRegistry for list:alter hook (optional, may not exist in tests)
173
+ const hooks = useHooks()
174
+
171
175
  // ============ STATE ============
172
176
  const items = ref([])
173
177
  const loading = ref(false)
@@ -192,6 +196,39 @@ export function useListPageBuilder(config = {}) {
192
196
  debounce: 300
193
197
  })
194
198
 
199
+ // ============ COLUMNS ============
200
+ const columnsMap = ref(new Map())
201
+
202
+ /**
203
+ * Add a column to the list
204
+ * @param {string} field - Field name (unique identifier)
205
+ * @param {object} columnConfig - Column configuration
206
+ * @param {string} [columnConfig.header] - Column header label
207
+ * @param {boolean} [columnConfig.sortable] - Whether column is sortable
208
+ * @param {string} [columnConfig.style] - Inline style
209
+ * @param {Function} [columnConfig.body] - Custom body template function
210
+ */
211
+ function addColumn(field, columnConfig = {}) {
212
+ columnsMap.value.set(field, {
213
+ field,
214
+ header: columnConfig.header || field.charAt(0).toUpperCase() + field.slice(1),
215
+ ...columnConfig
216
+ })
217
+ }
218
+
219
+ function removeColumn(field) {
220
+ columnsMap.value.delete(field)
221
+ }
222
+
223
+ function updateColumn(field, updates) {
224
+ const existing = columnsMap.value.get(field)
225
+ if (existing) {
226
+ columnsMap.value.set(field, { ...existing, ...updates })
227
+ }
228
+ }
229
+
230
+ const columns = computed(() => Array.from(columnsMap.value.values()))
231
+
195
232
  // ============ HEADER ACTIONS ============
196
233
  const headerActionsMap = ref(new Map())
197
234
 
@@ -232,6 +269,48 @@ export function useListPageBuilder(config = {}) {
232
269
 
233
270
  const headerActions = computed(() => getHeaderActions())
234
271
 
272
+ // ============ PERMISSION STATE ============
273
+
274
+ /**
275
+ * Whether the current user can create new entities
276
+ * Reactive computed based on manager.canCreate()
277
+ */
278
+ const canCreate = computed(() => manager.canCreate())
279
+
280
+ /**
281
+ * Whether the current user can delete entities (general scope check)
282
+ * For row-level checks, use canDeleteRow(row)
283
+ */
284
+ const canDelete = computed(() => manager.canDelete())
285
+
286
+ /**
287
+ * Check if user can edit a specific row (scope + silo check)
288
+ * @param {object} row - The row/record to check
289
+ * @returns {boolean}
290
+ */
291
+ function canEditRow(row) {
292
+ return manager.canUpdate(row)
293
+ }
294
+
295
+ /**
296
+ * Check if user can delete a specific row (scope + silo check)
297
+ * @param {object} row - The row/record to check
298
+ * @returns {boolean}
299
+ */
300
+ function canDeleteRow(row) {
301
+ return manager.canDelete(row)
302
+ }
303
+
304
+ /**
305
+ * Get actions for a row, filtering out those the user cannot perform
306
+ * This is the permission-aware version of getActions()
307
+ * @param {object} row - The row to get actions for
308
+ * @returns {Array} - Filtered list of actions the user can perform
309
+ */
310
+ function getRowActions(row) {
311
+ return getActions(row)
312
+ }
313
+
235
314
  /**
236
315
  * Add standard "Create" header action
237
316
  * Respects manager.canCreate() for visibility
@@ -520,70 +599,77 @@ export function useListPageBuilder(config = {}) {
520
599
 
521
600
  /**
522
601
  * Load filter options from API endpoints (for filters with optionsEndpoint)
602
+ * After loading, invokes filter:alter and {entity}:filter:alter hooks.
523
603
  */
524
604
  async function loadFilterOptions() {
525
605
  const entityConfig = entityFilters[entityName]
526
- if (!entityConfig?.filters) return
527
606
 
528
- for (const filterDef of entityConfig.filters) {
529
- if (!filterDef.optionsEndpoint) continue
530
-
531
- try {
532
- // Use manager.request for custom endpoints, or get another manager
533
- const optionsManager = filterDef.optionsEntity
534
- ? orchestrator.get(filterDef.optionsEntity)
535
- : manager
536
-
537
- let rawOptions
538
- if (filterDef.optionsEntity) {
539
- const response = await optionsManager.list({ page_size: 1000 })
540
- rawOptions = response.items || []
541
- } else {
542
- rawOptions = await manager.request('GET', filterDef.optionsEndpoint)
543
- rawOptions = Array.isArray(rawOptions) ? rawOptions : rawOptions?.items || []
544
- }
545
-
546
- // Build options array with "All" option first
547
- let finalOptions = [{ label: 'All', value: null }]
607
+ // Load options from API endpoints if configured
608
+ if (entityConfig?.filters) {
609
+ for (const filterDef of entityConfig.filters) {
610
+ if (!filterDef.optionsEndpoint) continue
611
+
612
+ try {
613
+ // Use manager.request for custom endpoints, or get another manager
614
+ const optionsManager = filterDef.optionsEntity
615
+ ? orchestrator.get(filterDef.optionsEntity)
616
+ : manager
617
+
618
+ let rawOptions
619
+ if (filterDef.optionsEntity) {
620
+ const response = await optionsManager.list({ page_size: 1000 })
621
+ rawOptions = response.items || []
622
+ } else {
623
+ rawOptions = await manager.request('GET', filterDef.optionsEndpoint)
624
+ rawOptions = Array.isArray(rawOptions) ? rawOptions : rawOptions?.items || []
625
+ }
548
626
 
549
- // Add null option if configured
550
- if (filterDef.includeNull) {
551
- finalOptions.push(filterDef.includeNull)
552
- }
627
+ // Build options array with "All" option first
628
+ let finalOptions = [{ label: 'All', value: null }]
553
629
 
554
- // Map fetched options to standard { label, value } format
555
- // optionLabelField/optionValueField specify which API fields to use
556
- // labelMap: { value: 'Label' } for custom value-to-label mapping
557
- // labelFallback: function(value) for unknown values (default: snakeToTitle)
558
- const labelField = filterDef.optionLabelField || 'label'
559
- const valueField = filterDef.optionValueField || 'value'
560
- const labelMap = filterDef.labelMap || {}
561
- const labelFallback = filterDef.labelFallback || snakeToTitle
562
-
563
- const mappedOptions = rawOptions.map(opt => {
564
- const value = opt[valueField] ?? opt.id ?? opt
565
- // Priority: labelMap > API label field > fallback function
566
- let label = labelMap[value]
567
- if (!label) {
568
- label = opt[labelField] || opt.name
569
- }
570
- if (!label) {
571
- label = labelFallback(value)
630
+ // Add null option if configured
631
+ if (filterDef.includeNull) {
632
+ finalOptions.push(filterDef.includeNull)
572
633
  }
573
- return { label, value }
574
- })
575
634
 
576
- finalOptions = [...finalOptions, ...mappedOptions]
635
+ // Map fetched options to standard { label, value } format
636
+ // optionLabelField/optionValueField specify which API fields to use
637
+ // labelMap: { value: 'Label' } for custom value-to-label mapping
638
+ // labelFallback: function(value) for unknown values (default: snakeToTitle)
639
+ const labelField = filterDef.optionLabelField || 'label'
640
+ const valueField = filterDef.optionValueField || 'value'
641
+ const labelMap = filterDef.labelMap || {}
642
+ const labelFallback = filterDef.labelFallback || snakeToTitle
643
+
644
+ const mappedOptions = rawOptions.map(opt => {
645
+ const value = opt[valueField] ?? opt.id ?? opt
646
+ // Priority: labelMap > API label field > fallback function
647
+ let label = labelMap[value]
648
+ if (!label) {
649
+ label = opt[labelField] || opt.name
650
+ }
651
+ if (!label) {
652
+ label = labelFallback(value)
653
+ }
654
+ return { label, value }
655
+ })
656
+
657
+ finalOptions = [...finalOptions, ...mappedOptions]
577
658
 
578
- // Update filter options in map
579
- const existing = filtersMap.value.get(filterDef.name)
580
- if (existing) {
581
- filtersMap.value.set(filterDef.name, { ...existing, options: finalOptions })
659
+ // Update filter options in map
660
+ const existing = filtersMap.value.get(filterDef.name)
661
+ if (existing) {
662
+ filtersMap.value.set(filterDef.name, { ...existing, options: finalOptions })
663
+ }
664
+ } catch (error) {
665
+ console.warn(`Failed to load options for filter ${filterDef.name}:`, error)
582
666
  }
583
- } catch (error) {
584
- console.warn(`Failed to load options for filter ${filterDef.name}:`, error)
585
667
  }
586
668
  }
669
+
670
+ // Invoke filter:alter hooks after all options are loaded (always runs)
671
+ await invokeFilterAlterHook()
672
+
587
673
  // Trigger Vue reactivity by replacing the Map reference
588
674
  filtersMap.value = new Map(filtersMap.value)
589
675
  }
@@ -915,6 +1001,184 @@ export function useListPageBuilder(config = {}) {
915
1001
  // Note: filterValues changes are handled directly in updateFilters() and clearFilters()
916
1002
  // to avoid relying on watch reactivity which can be unreliable with object mutations
917
1003
 
1004
+ // ============ LIST:ALTER HOOK ============
1005
+
1006
+ /**
1007
+ * Invoke list:alter hooks to allow modules to modify list configuration
1008
+ *
1009
+ * Builds a config snapshot from current state, passes it through the hook chain,
1010
+ * and applies any modifications back to the internal maps.
1011
+ *
1012
+ * Hook context structure:
1013
+ * @typedef {object} ListAlterConfig
1014
+ * @property {string} entity - Entity name
1015
+ * @property {Array} columns - Array of column definitions
1016
+ * @property {Array} filters - Array of filter definitions
1017
+ * @property {Array} actions - Array of row action definitions
1018
+ * @property {Array} headerActions - Array of header action definitions
1019
+ *
1020
+ * @example
1021
+ * // Register a hook to add a custom column
1022
+ * hooks.register('list:alter', (config) => {
1023
+ * if (config.entity === 'books') {
1024
+ * config.columns.push({ field: 'custom', header: 'Custom' })
1025
+ * }
1026
+ * return config
1027
+ * })
1028
+ *
1029
+ * @example
1030
+ * // Register entity-specific hook
1031
+ * hooks.register('books:list:alter', (config) => {
1032
+ * config.filters.push({ name: 'year', type: 'select', options: [...] })
1033
+ * return config
1034
+ * })
1035
+ */
1036
+ async function invokeListAlterHook() {
1037
+ if (!hooks) return
1038
+
1039
+ // Build config snapshot from current state
1040
+ const configSnapshot = {
1041
+ entity,
1042
+ columns: Array.from(columnsMap.value.values()),
1043
+ filters: Array.from(filtersMap.value.values()),
1044
+ actions: Array.from(actionsMap.value.values()),
1045
+ headerActions: Array.from(headerActionsMap.value.values()),
1046
+ }
1047
+
1048
+ // Context passed to handlers
1049
+ const hookContext = { entity, manager }
1050
+
1051
+ // Invoke generic list:alter hook
1052
+ let alteredConfig = await hooks.alter('list:alter', configSnapshot, hookContext)
1053
+
1054
+ // Invoke entity-specific hook: {entity}:list:alter
1055
+ const entityHookName = `${entity}:list:alter`
1056
+ if (hooks.hasHook(entityHookName)) {
1057
+ alteredConfig = await hooks.alter(entityHookName, alteredConfig, hookContext)
1058
+ }
1059
+
1060
+ // Apply altered config back to the maps
1061
+ applyAlteredConfig(alteredConfig)
1062
+ }
1063
+
1064
+ // ============ FILTER:ALTER HOOK ============
1065
+
1066
+ /**
1067
+ * Invoke filter:alter hooks to allow modules to modify filter options
1068
+ *
1069
+ * Builds a filters snapshot from current state, passes it through the hook chain,
1070
+ * and applies any modifications back to the filtersMap. Runs after API options are
1071
+ * loaded but before list:alter hook.
1072
+ *
1073
+ * Config object structure passed to handlers:
1074
+ * @typedef {object} FilterAlterConfig
1075
+ * @property {string} entity - Entity name (for conditional logic)
1076
+ * @property {Array<object>} filters - Array of filter definitions with loaded options
1077
+ * @property {string} filters[].name - Filter name/identifier
1078
+ * @property {string} filters[].type - Filter type (select, multiselect, etc.)
1079
+ * @property {Array<{label: string, value: *}>} filters[].options - Available options
1080
+ *
1081
+ * Hook invocation order:
1082
+ * 1. `filter:alter` - Generic hook for all entities
1083
+ * 2. `{entity}:filter:alter` - Entity-specific hook (e.g., `books:filter:alter`)
1084
+ *
1085
+ * @example
1086
+ * // Add custom filter options dynamically
1087
+ * hooks.register('filter:alter', (config) => {
1088
+ * if (config.entity === 'books') {
1089
+ * const statusFilter = config.filters.find(f => f.name === 'status')
1090
+ * if (statusFilter) {
1091
+ * statusFilter.options.push({ label: 'Archived', value: 'archived' })
1092
+ * }
1093
+ * }
1094
+ * return config
1095
+ * })
1096
+ *
1097
+ * @example
1098
+ * // Filter options based on user context (entity-specific)
1099
+ * hooks.register('books:filter:alter', (config) => {
1100
+ * const genreFilter = config.filters.find(f => f.name === 'genre')
1101
+ * if (genreFilter) {
1102
+ * // Remove restricted options
1103
+ * genreFilter.options = genreFilter.options.filter(o => o.value !== 'restricted')
1104
+ * }
1105
+ * return config
1106
+ * })
1107
+ */
1108
+ async function invokeFilterAlterHook() {
1109
+ if (!hooks) return
1110
+
1111
+ // Build filters snapshot from current state
1112
+ // Entity is included in the snapshot for handlers to filter by entity
1113
+ const filterSnapshot = {
1114
+ entity,
1115
+ filters: Array.from(filtersMap.value.values()),
1116
+ }
1117
+
1118
+ // Invoke generic filter:alter hook
1119
+ let alteredFilters = await hooks.alter('filter:alter', filterSnapshot)
1120
+
1121
+ // Invoke entity-specific hook: {entity}:filter:alter
1122
+ const entityHookName = `${entity}:filter:alter`
1123
+ if (hooks.hasHook(entityHookName)) {
1124
+ alteredFilters = await hooks.alter(entityHookName, alteredFilters)
1125
+ }
1126
+
1127
+ // Apply altered filters back to the map
1128
+ if (alteredFilters.filters) {
1129
+ filtersMap.value.clear()
1130
+ for (const filter of alteredFilters.filters) {
1131
+ filtersMap.value.set(filter.name, filter)
1132
+ // Preserve existing filter values or use defaults
1133
+ if (filterValues.value[filter.name] === undefined) {
1134
+ filterValues.value[filter.name] = filter.default ?? null
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * Apply altered configuration back to internal maps
1142
+ * @param {ListAlterConfig} alteredConfig - The modified configuration
1143
+ */
1144
+ function applyAlteredConfig(alteredConfig) {
1145
+ // Apply columns
1146
+ if (alteredConfig.columns) {
1147
+ columnsMap.value.clear()
1148
+ for (const col of alteredConfig.columns) {
1149
+ columnsMap.value.set(col.field, col)
1150
+ }
1151
+ }
1152
+
1153
+ // Apply filters
1154
+ if (alteredConfig.filters) {
1155
+ filtersMap.value.clear()
1156
+ for (const filter of alteredConfig.filters) {
1157
+ filtersMap.value.set(filter.name, filter)
1158
+ // Preserve existing filter values or use defaults
1159
+ if (filterValues.value[filter.name] === undefined) {
1160
+ filterValues.value[filter.name] = filter.default ?? null
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ // Apply actions
1166
+ if (alteredConfig.actions) {
1167
+ actionsMap.value.clear()
1168
+ for (const action of alteredConfig.actions) {
1169
+ actionsMap.value.set(action.name, action)
1170
+ }
1171
+ }
1172
+
1173
+ // Apply header actions
1174
+ if (alteredConfig.headerActions) {
1175
+ headerActionsMap.value.clear()
1176
+ for (const action of alteredConfig.headerActions) {
1177
+ headerActionsMap.value.set(action.name, action)
1178
+ }
1179
+ }
1180
+ }
1181
+
918
1182
  // ============ LIFECYCLE ============
919
1183
  // Initialize from registry immediately (sync)
920
1184
  initFromRegistry()
@@ -929,6 +1193,10 @@ export function useListPageBuilder(config = {}) {
929
1193
  await loadFilterOptions()
930
1194
  }
931
1195
 
1196
+ // Invoke list:alter hooks to allow modules to modify configuration
1197
+ // This runs after initFromRegistry() and loadFilterOptions(), before loadItems()
1198
+ await invokeListAlterHook()
1199
+
932
1200
  // Load data
933
1201
  if (loadOnMount && manager) {
934
1202
  loadItems()
@@ -1022,6 +1290,9 @@ export function useListPageBuilder(config = {}) {
1022
1290
  // Cards
1023
1291
  cards: cards.value,
1024
1292
 
1293
+ // Columns
1294
+ columns: columns.value,
1295
+
1025
1296
  // Table data
1026
1297
  items: displayItems.value,
1027
1298
  loading: loading.value,
@@ -1090,6 +1361,12 @@ export function useListPageBuilder(config = {}) {
1090
1361
  searchConfig,
1091
1362
  setSearch,
1092
1363
 
1364
+ // Columns
1365
+ columns,
1366
+ addColumn,
1367
+ removeColumn,
1368
+ updateColumn,
1369
+
1093
1370
  // Header Actions
1094
1371
  headerActions,
1095
1372
  addHeaderAction,
@@ -1124,10 +1401,17 @@ export function useListPageBuilder(config = {}) {
1124
1401
  addAction,
1125
1402
  removeAction,
1126
1403
  getActions,
1404
+ getRowActions, // Permission-aware alias for getActions
1127
1405
  addViewAction,
1128
1406
  addEditAction,
1129
1407
  addDeleteAction,
1130
1408
 
1409
+ // Permissions
1410
+ canCreate,
1411
+ canDelete,
1412
+ canEditRow,
1413
+ canDeleteRow,
1414
+
1131
1415
  // Data
1132
1416
  loadItems,
1133
1417