qdadm 0.13.0 → 0.14.2

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.
@@ -1,4 +1,4 @@
1
- import { ref, computed, provide, onUnmounted } from 'vue'
1
+ import { ref, computed, provide, inject, onUnmounted } from 'vue'
2
2
  import { useRoute, useRouter } from 'vue-router'
3
3
  import { useToast } from 'primevue/usetoast'
4
4
  import { useDirtyState } from './useDirtyState'
@@ -20,6 +20,9 @@ import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
20
20
  * } = useBareForm({
21
21
  * getState: () => ({ form: form.value }),
22
22
  * routePrefix: 'agents', // for cancel() navigation
23
+ * entityName: 'Agent', // for auto-title (optional, or use 'entity' for auto-lookup)
24
+ * labelField: 'name', // field to use as entity label (optional)
25
+ * entity: 'agents', // EntityManager name for auto metadata (optional)
23
26
  * guard: true, // enable unsaved changes modal
24
27
  * onGuardSave: () => save() // optional save callback for guard modal
25
28
  * })
@@ -32,20 +35,25 @@ import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
32
35
  * - Common computed (isEdit, entityId)
33
36
  * - Navigation helpers (cancel)
34
37
  * - Access to router, route, toast
38
+ * - Auto page title via provide (for PageHeader)
35
39
  *
36
40
  * @param {Object} options
37
41
  * @param {Function} options.getState - Function returning current form state for comparison
38
42
  * @param {string} options.routePrefix - Route name for cancel navigation (default: '')
43
+ * @param {string} options.entityName - Display name for entity (default: from manager or derived from routePrefix)
44
+ * @param {string|Function} options.labelField - Field name or function to extract entity label (default: from manager or 'name')
45
+ * @param {string|Ref|Object} options.entity - EntityManager name (string) for auto metadata, OR entity data (Ref/Object) for breadcrumb
39
46
  * @param {boolean} options.guard - Enable unsaved changes guard (default: true)
40
47
  * @param {Function} options.onGuardSave - Callback for save button in guard modal
41
48
  * @param {Function} options.getId - Custom function to extract entity ID from route (optional)
42
- * @param {Ref|Object} options.entity - Entity data for dynamic breadcrumb labels (optional)
43
49
  * @param {Function} options.breadcrumbLabel - Callback (entity) => string for custom breadcrumb label (optional)
44
50
  */
45
51
  export function useBareForm(options = {}) {
46
52
  const {
47
53
  getState,
48
54
  routePrefix = '',
55
+ entityName = null,
56
+ labelField = null,
49
57
  guard = true,
50
58
  onGuardSave = null,
51
59
  getId = null,
@@ -57,6 +65,19 @@ export function useBareForm(options = {}) {
57
65
  throw new Error('useBareForm requires a getState function')
58
66
  }
59
67
 
68
+ // Try to get EntityManager metadata if entity is a string
69
+ let manager = null
70
+ if (typeof entity === 'string') {
71
+ const orchestrator = inject('qdadmOrchestrator', null)
72
+ if (orchestrator) {
73
+ try {
74
+ manager = orchestrator.get(entity)
75
+ } catch {
76
+ // Manager not found, continue without it
77
+ }
78
+ }
79
+ }
80
+
60
81
  // Router, route, toast - common dependencies
61
82
  const router = useRouter()
62
83
  const route = useRoute()
@@ -88,8 +109,53 @@ export function useBareForm(options = {}) {
88
109
  provide('isFieldDirty', isFieldDirty)
89
110
  provide('dirtyFields', dirtyFields)
90
111
 
112
+ // Resolve entityName: explicit > manager > derived from routePrefix
113
+ const derivedEntityName = routePrefix
114
+ ? routePrefix.charAt(0).toUpperCase() + routePrefix.slice(1).replace(/s$/, '')
115
+ : null
116
+ const effectiveEntityName = entityName || manager?.label || derivedEntityName
117
+
118
+ // Resolve labelField: explicit > manager > default 'name'
119
+ const effectiveLabelField = labelField || manager?.labelField || 'name'
120
+
121
+ // Provide entity context for child components (e.g., SeverityTag auto-discovery)
122
+ if (routePrefix) {
123
+ const entityNameForProvider = routePrefix.endsWith('s') ? routePrefix : routePrefix + 's'
124
+ provide('mainEntity', entityNameForProvider)
125
+ } else if (typeof entity === 'string') {
126
+ provide('mainEntity', entity)
127
+ }
128
+
129
+ // Auto page title parts for PageHeader
130
+ const getEntityLabel = () => {
131
+ const state = getState()
132
+ const formData = state.form || state
133
+ if (!formData) return null
134
+ // Use manager.getEntityLabel if available, otherwise use effectiveLabelField
135
+ if (manager) {
136
+ return manager.getEntityLabel(formData)
137
+ }
138
+ if (typeof effectiveLabelField === 'function') {
139
+ return effectiveLabelField(formData)
140
+ }
141
+ return formData[effectiveLabelField] || null
142
+ }
143
+
144
+ const pageTitleParts = computed(() => ({
145
+ action: isEdit.value ? 'Edit' : 'Create',
146
+ entityName: effectiveEntityName,
147
+ entityLabel: isEdit.value ? getEntityLabel() : null
148
+ }))
149
+
150
+ // Provide title parts for automatic PageHeader consumption
151
+ if (effectiveEntityName) {
152
+ provide('qdadmPageTitleParts', pageTitleParts)
153
+ }
154
+
91
155
  // Breadcrumb (auto-generated from route path, with optional entity for dynamic labels)
92
- const { breadcrumbItems } = useBreadcrumb({ entity, getEntityLabel: breadcrumbLabel })
156
+ // Only pass entity to breadcrumb if it's actual entity data (not a string manager name)
157
+ const breadcrumbEntity = typeof entity === 'string' ? null : entity
158
+ const { breadcrumbItems } = useBreadcrumb({ entity: breadcrumbEntity, getEntityLabel: breadcrumbLabel })
93
159
 
94
160
  // Unsaved changes guard
95
161
  let guardDialog = null
@@ -117,6 +183,9 @@ export function useBareForm(options = {}) {
117
183
  route,
118
184
  toast,
119
185
 
186
+ // Manager (if resolved from entity string)
187
+ manager,
188
+
120
189
  // State
121
190
  loading,
122
191
  saving,
@@ -138,6 +207,9 @@ export function useBareForm(options = {}) {
138
207
  breadcrumb: breadcrumbItems,
139
208
 
140
209
  // Guard dialog (for UnsavedChangesDialog component)
141
- guardDialog
210
+ guardDialog,
211
+
212
+ // Title helpers
213
+ pageTitleParts
142
214
  }
143
215
  }
@@ -131,8 +131,16 @@ export function useBreadcrumb(options = {}) {
131
131
  return items
132
132
  }
133
133
 
134
+ // Action segments that should be excluded from breadcrumb
135
+ const actionSegments = ['edit', 'create', 'show', 'view', 'new', 'delete']
136
+
134
137
  /**
135
138
  * Build breadcrumb automatically from route path
139
+ *
140
+ * Breadcrumb shows navigable parents only:
141
+ * - Excludes action segments (edit, create, show, etc.)
142
+ * - Excludes IDs
143
+ * - All items have links (to navigate back to parent)
136
144
  */
137
145
  function buildFromPath(entity, getEntityLabel) {
138
146
  const items = []
@@ -147,19 +155,17 @@ export function useBreadcrumb(options = {}) {
147
155
  const segment = segments[i]
148
156
  currentPath += `/${segment}`
149
157
 
150
- // Handle IDs: numeric, UUID, ULID, or any alphanumeric string > 10 chars - use entity label instead
158
+ // Skip action segments (edit, create, show, etc.)
159
+ if (actionSegments.includes(segment.toLowerCase())) {
160
+ continue
161
+ }
162
+
163
+ // Skip IDs: numeric, UUID, ULID, or any alphanumeric string > 10 chars
151
164
  const isId = /^\d+$/.test(segment) || // numeric
152
165
  segment.match(/^[0-9a-f-]{36}$/i) || // UUID
153
166
  segment.match(/^[0-7][0-9a-hjkmnp-tv-z]{25}$/i) || // ULID
154
- (segment.match(/^[a-z0-9]+$/i) && segment.length > 10) // Generated ID (like LocalStorage)
167
+ (segment.match(/^[a-z0-9]+$/i) && segment.length > 10) // Generated ID
155
168
  if (isId) {
156
- // If we have entity data, show its label instead of the ID
157
- if (entity && getEntityLabel) {
158
- const entityLabel = getEntityLabel(entity)
159
- if (entityLabel) {
160
- items.push({ label: entityLabel, to: null, icon: null })
161
- }
162
- }
163
169
  continue
164
170
  }
165
171
 
@@ -179,14 +185,17 @@ export function useBreadcrumb(options = {}) {
179
185
  icon: i === 0 ? iconMap[segment] : null
180
186
  }
181
187
 
182
- // Last item has no link
183
- if (i === segments.length - 1) {
184
- item.to = null
185
- }
186
-
187
188
  items.push(item)
188
189
  }
189
190
 
191
+ // Remove last item if it matches current route (we only show parents)
192
+ if (items.length > 1) {
193
+ const lastItem = items[items.length - 1]
194
+ if (lastItem.to?.name === route.name) {
195
+ items.pop()
196
+ }
197
+ }
198
+
190
199
  return items
191
200
  }
192
201
 
@@ -26,7 +26,7 @@
26
26
  * })
27
27
  * ```
28
28
  */
29
- import { ref, watch, onMounted, inject } from 'vue'
29
+ import { ref, computed, watch, onMounted, inject, provide } from 'vue'
30
30
  import { useBareForm } from './useBareForm'
31
31
  import { deepClone } from '../utils/transformers'
32
32
 
@@ -83,6 +83,8 @@ export function useForm(options = {}) {
83
83
  checkDirty,
84
84
  // Helpers
85
85
  cancel,
86
+ // Breadcrumb
87
+ breadcrumb,
86
88
  // Guard dialog for unsaved changes
87
89
  guardDialog
88
90
  } = useBareForm({
@@ -224,6 +226,42 @@ export function useForm(options = {}) {
224
226
  load()
225
227
  })
226
228
 
229
+ // ============ COMPUTED TITLE ============
230
+
231
+ /**
232
+ * Entity display label (e.g., "David Berlioz" for an agent)
233
+ * Uses manager.getEntityLabel() with labelField config
234
+ */
235
+ const entityLabel = computed(() => {
236
+ return manager.getEntityLabel(form.value)
237
+ })
238
+
239
+ /**
240
+ * Auto-generated page title
241
+ * - Edit mode: "Edit Agent: David Berlioz"
242
+ * - Create mode: "Create Agent"
243
+ */
244
+ const pageTitle = computed(() => {
245
+ if (isEdit.value) {
246
+ const label = entityLabel.value
247
+ return label ? `Edit ${entityName}: ${label}` : `Edit ${entityName}`
248
+ }
249
+ return `Create ${entityName}`
250
+ })
251
+
252
+ /**
253
+ * Structured page title for decorated rendering
254
+ * Returns { action, entityName, entityLabel } for custom styling
255
+ */
256
+ const pageTitleParts = computed(() => ({
257
+ action: isEdit.value ? 'Edit' : 'Create',
258
+ entityName,
259
+ entityLabel: isEdit.value ? entityLabel.value : null
260
+ }))
261
+
262
+ // Provide title parts for automatic PageHeader consumption
263
+ provide('qdadmPageTitleParts', pageTitleParts)
264
+
227
265
  return {
228
266
  // Manager access
229
267
  manager,
@@ -248,7 +286,15 @@ export function useForm(options = {}) {
248
286
  checkDirty,
249
287
  isFieldDirty,
250
288
 
289
+ // Breadcrumb (auto-generated from route)
290
+ breadcrumb,
291
+
251
292
  // Guard dialog (for UnsavedChangesDialog - pass to PageLayout)
252
- guardDialog
293
+ guardDialog,
294
+
295
+ // Title helpers
296
+ entityLabel,
297
+ pageTitle,
298
+ pageTitleParts
253
299
  }
254
300
  }
@@ -1,4 +1,4 @@
1
- import { ref, computed, watch, onMounted, inject } from 'vue'
1
+ 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'
@@ -155,6 +155,9 @@ export function useListPageBuilder(config = {}) {
155
155
  }
156
156
  const manager = orchestrator.get(entity)
157
157
 
158
+ // Provide entity context for child components (e.g., SeverityTag auto-discovery)
159
+ provide('mainEntity', entity)
160
+
158
161
  // Read config from manager with option overrides
159
162
  const entityName = config.entityName ?? manager.label
160
163
  const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
@@ -663,71 +666,35 @@ export function useListPageBuilder(config = {}) {
663
666
  searchConfig.value = { ...searchConfig.value, ...searchCfg }
664
667
  }
665
668
 
666
- // ============ SMART FILTER MODE ============
667
- // Threshold priority: config option > manager.localFilterThreshold > default (100)
668
- const resolvedThreshold = computed(() => {
669
- if (autoFilterThreshold !== 100) return autoFilterThreshold // Explicit config
670
- return manager?.localFilterThreshold ?? 100
671
- })
669
+ // ============ CACHE MODE ============
670
+ // EntityManager handles caching and filtering automatically via query()
671
+ // This computed applies any custom local_filter callbacks on top
672
+ const fromCache = ref(false)
672
673
 
673
- const effectiveFilterMode = computed(() => {
674
- if (filterMode !== 'auto') return filterMode
675
- // Switch to local if we have few items
676
- return items.value.length > 0 && items.value.length < resolvedThreshold.value ? 'local' : 'manager'
677
- })
678
-
679
- // Computed for local filtering
680
- // Only applied when effectiveFilterMode === 'local'
681
- // In manager mode, items are already filtered by the manager
682
674
  const filteredItems = computed(() => {
683
- const isLocalMode = effectiveFilterMode.value === 'local'
684
-
685
- // In manager mode, return items as-is (manager already filtered)
686
- if (!isLocalMode) {
687
- return items.value
688
- }
689
-
690
- // Local mode: apply all filtering client-side
691
675
  let result = [...items.value]
692
676
 
693
- // Apply search
694
- // local_search: false = manager only, skip in local mode
695
- if (searchQuery.value && searchConfig.value.local_search !== false) {
696
- const query = searchQuery.value.toLowerCase()
697
- // Default: search on configured fields
698
- const localSearch = searchConfig.value.local_search || (
699
- searchConfig.value.fields?.length
700
- ? (item, q) => searchConfig.value.fields.some(field =>
701
- String(item[field] || '').toLowerCase().includes(q)
702
- )
703
- : null
704
- )
705
- if (localSearch) {
706
- result = result.filter(item => localSearch(item, query))
707
- }
708
- }
709
-
710
- // Apply filters
677
+ // Apply custom local_filter callbacks (UI-specific post-filters)
711
678
  for (const [name, value] of Object.entries(filterValues.value)) {
712
679
  if (value === null || value === undefined || value === '') continue
713
680
  const filterDef = filtersMap.value.get(name)
714
681
 
715
- // local_filter: false = manager only, skip in local mode
716
- if (filterDef?.local_filter === false) continue
682
+ // Only apply if there's a custom local_filter callback
683
+ if (typeof filterDef?.local_filter !== 'function') continue
717
684
 
718
- // Default: simple equality on field
719
- const localFilter = filterDef?.local_filter || ((item, val) => {
720
- const itemValue = item[name]
721
- if (val === '__null__') return itemValue === null || itemValue === undefined
722
- return itemValue === val
723
- })
724
- result = result.filter(item => localFilter(item, value))
685
+ result = result.filter(item => filterDef.local_filter(item, value))
686
+ }
687
+
688
+ // Apply custom local_search callback
689
+ if (searchQuery.value && typeof searchConfig.value.local_search === 'function') {
690
+ const query = searchQuery.value.toLowerCase()
691
+ result = result.filter(item => searchConfig.value.local_search(item, query))
725
692
  }
726
693
 
727
694
  return result
728
695
  })
729
696
 
730
- // Items to display - always use filteredItems (handles both modes correctly)
697
+ // Items to display
731
698
  const displayItems = computed(() => filteredItems.value)
732
699
 
733
700
  // ============ LOADING ============
@@ -736,36 +703,40 @@ export function useListPageBuilder(config = {}) {
736
703
  async function loadItems(extraParams = {}, { force = false } = {}) {
737
704
  if (!manager) return
738
705
 
739
- // Skip API call if local filtering and data already loaded (unless forced)
740
- if (!force && effectiveFilterMode.value === 'local' && items.value.length > 0) {
741
- return
706
+ // If forced, invalidate the manager's cache first
707
+ if (force && manager.invalidateCache) {
708
+ manager.invalidateCache()
742
709
  }
743
710
 
744
711
  loading.value = true
745
712
  try {
713
+ // Build query params
746
714
  let params = { ...extraParams }
747
715
 
748
- if (serverSide) {
749
- params.page = page.value
750
- params.page_size = pageSize.value
751
- if (sortField.value) {
752
- params.sort_by = sortField.value
753
- params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
754
- }
716
+ // Pagination and sorting
717
+ params.page = page.value
718
+ params.page_size = pageSize.value
719
+ if (sortField.value) {
720
+ params.sort_by = sortField.value
721
+ params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
755
722
  }
756
723
 
757
- // Add search param to manager (only if no local_search callback)
758
- if (searchQuery.value && searchConfig.value.fields?.length && !searchConfig.value.local_search) {
724
+ // Add search param (skip if custom local_search callback)
725
+ if (searchQuery.value && typeof searchConfig.value.local_search !== 'function') {
759
726
  params.search = searchQuery.value
760
727
  }
761
728
 
762
- // Add filter values to manager (skip filters with local_filter - they're local-only)
729
+ // Add filter values (skip filters with custom local_filter callback)
730
+ const filters = {}
763
731
  for (const [name, value] of Object.entries(filterValues.value)) {
764
732
  if (value === null || value === undefined || value === '') continue
765
733
  const filterDef = filtersMap.value.get(name)
766
- // Skip virtual filters (those with local_filter) - not sent to manager
767
- if (filterDef?.local_filter) continue
768
- params[name] = value
734
+ // Skip filters with custom local_filter - they're applied in filteredItems
735
+ if (typeof filterDef?.local_filter === 'function') continue
736
+ filters[name] = value
737
+ }
738
+ if (Object.keys(filters).length > 0) {
739
+ params.filters = filters
769
740
  }
770
741
 
771
742
  // Hook: modify params before request
@@ -773,7 +744,13 @@ export function useListPageBuilder(config = {}) {
773
744
  params = onBeforeLoad(params) || params
774
745
  }
775
746
 
776
- const response = await manager.list(params)
747
+ // Use manager.query() for automatic cache handling
748
+ const response = manager.query
749
+ ? await manager.query(params)
750
+ : await manager.list(params)
751
+
752
+ // Track if response came from cache
753
+ fromCache.value = response.fromCache || false
777
754
 
778
755
  // Hook: transform response
779
756
  let processedData
@@ -812,13 +789,13 @@ export function useListPageBuilder(config = {}) {
812
789
  pageSize.value = event.rows
813
790
  // Save page size preference to cookie
814
791
  setCookie(COOKIE_NAME, event.rows, COOKIE_EXPIRY_DAYS)
815
- if (serverSide) loadItems()
792
+ loadItems()
816
793
  }
817
794
 
818
795
  function onSort(event) {
819
796
  sortField.value = event.sortField
820
797
  sortOrder.value = event.sortOrder
821
- if (serverSide) loadItems()
798
+ loadItems()
822
799
  }
823
800
 
824
801
  // ============ NAVIGATION ============
@@ -827,11 +804,11 @@ export function useListPageBuilder(config = {}) {
827
804
  }
828
805
 
829
806
  function goToEdit(item) {
830
- router.push({ name: `${routePrefix}-edit`, params: { [resolvedDataKey]: item[resolvedDataKey] } })
807
+ router.push({ name: `${routePrefix}-edit`, params: { id: item[resolvedDataKey] } })
831
808
  }
832
809
 
833
810
  function goToShow(item) {
834
- router.push({ name: `${routePrefix}-show`, params: { [resolvedDataKey]: item[resolvedDataKey] } })
811
+ router.push({ name: `${routePrefix}-show`, params: { id: item[resolvedDataKey] } })
835
812
  }
836
813
 
837
814
  // ============ DELETE ============
@@ -1126,7 +1103,7 @@ export function useListPageBuilder(config = {}) {
1126
1103
  filters,
1127
1104
  filterValues,
1128
1105
  filteredItems,
1129
- effectiveFilterMode,
1106
+ fromCache, // true if last query used cache
1130
1107
  addFilter,
1131
1108
  removeFilter,
1132
1109
  setFilterValue,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * useManager - Access EntityManager by name
3
+ *
4
+ * @param {string} entityName - Entity name (e.g., 'jobs', 'stories')
5
+ * @returns {EntityManager|null} - The entity manager or null if not found
6
+ *
7
+ * @example
8
+ * const jobs = useManager('jobs')
9
+ * const severity = jobs.getSeverity('status', 'completed')
10
+ */
11
+ import { inject } from 'vue'
12
+
13
+ export function useManager(entityName) {
14
+ const orchestrator = inject('qdadmOrchestrator')
15
+ if (!orchestrator) {
16
+ console.warn('[qdadm] useManager: orchestrator not available')
17
+ return null
18
+ }
19
+ return orchestrator.get(entityName)
20
+ }