qdadm 0.38.0 → 0.40.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.
@@ -1,43 +1,33 @@
1
1
  /**
2
2
  * useNavContext - Route-aware navigation context for breadcrumb and navlinks
3
3
  *
4
- * Builds navigation from route path pattern analysis (not heuristics).
4
+ * Uses semantic breadcrumb as the source of truth for navigation structure.
5
+ * Semantic breadcrumb is computed from route path and registered routes.
5
6
  *
6
- * The route path pattern defines the navigation structure:
7
- * - Static segments (e.g., 'books') → entity list
8
- * - Param segments (e.g., ':id', ':bookId') → entity item
9
- * - Action segments (e.g., 'edit', 'create') → ignored
10
- *
11
- * Route meta configuration:
12
- * - meta.entity: Entity managed by this route (required)
13
- * - meta.parent: Parent entity config for nested routes
14
- * - parent.entity: Parent entity name
15
- * - parent.param: Route param for parent ID
7
+ * Semantic breadcrumb kinds:
8
+ * - entity-list: Entity collection (e.g., /books)
9
+ * - entity-show: Entity instance view (e.g., /books/1)
10
+ * - entity-edit: Entity instance edit (e.g., /books/1/edit)
11
+ * - entity-create: Entity creation (e.g., /books/create)
12
+ * - route: Generic route (e.g., /settings)
16
13
  *
17
14
  * Examples:
18
- * Path: /books meta: { entity: 'books' }
19
- * → Home > Books
20
- *
21
- * Path: /books/:id/edit meta: { entity: 'books' }
22
- * → Home > Books > "Le Petit Prince"
23
- *
24
- * Path: /books/:bookId/loans meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
25
- * → Home > Books > "Le Petit Prince" > Loans
26
- *
27
- * Path: /books/:bookId/loans/:id/edit meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
28
- * → Home > Books > "Le Petit Prince" > Loans > "Loan #abc123"
15
+ * Path: /books [{ kind: 'entity-list', entity: 'books' }]
16
+ * Path: /books/1/edit [{ kind: 'entity-list', entity: 'books' }, { kind: 'entity-edit', entity: 'books', id: '1' }]
17
+ * Path: /books/stats → [{ kind: 'entity-list', entity: 'books' }, { kind: 'route', route: 'book-stats' }]
29
18
  */
30
19
  import { ref, computed, watch, inject } from 'vue'
31
20
  import { useRoute, useRouter } from 'vue-router'
32
21
  import { getSiblingRoutes } from '../module/moduleRegistry.js'
33
-
34
- // Action segments that don't appear in breadcrumb
35
- const ACTION_SEGMENTS = ['edit', 'create', 'new', 'show', 'view', 'delete']
22
+ import { useSemanticBreadcrumb } from './useSemanticBreadcrumb.js'
36
23
 
37
24
  export function useNavContext(options = {}) {
38
25
  const route = useRoute()
39
26
  const router = useRouter()
40
27
 
28
+ // Semantic breadcrumb as source of truth
29
+ const { breadcrumb: semanticBreadcrumb } = useSemanticBreadcrumb()
30
+
41
31
  // Injected dependencies
42
32
  const orchestrator = inject('qdadmOrchestrator', null)
43
33
  const homeRouteName = inject('qdadmHomeRoute', null)
@@ -48,9 +38,6 @@ export function useNavContext(options = {}) {
48
38
  // or injected from parent (for child pages)
49
39
  const breadcrumbEntities = options.breadcrumbEntities ?? inject('qdadmBreadcrumbEntities', null)
50
40
 
51
- // Entity data cache
52
- const entityDataCache = ref(new Map())
53
-
54
41
  function getManager(entityName) {
55
42
  return orchestrator?.get(entityName)
56
43
  }
@@ -59,122 +46,53 @@ export function useNavContext(options = {}) {
59
46
  return router.getRoutes().some(r => r.name === name)
60
47
  }
61
48
 
62
- // ============================================================================
63
- // PATH PATTERN ANALYSIS
64
- // ============================================================================
65
-
66
- /**
67
- * Parse route path pattern into typed segments
68
- *
69
- * Input: '/books/:bookId/loans/:id/edit'
70
- * Output: [
71
- * { type: 'static', value: 'books' },
72
- * { type: 'param', value: 'bookId' },
73
- * { type: 'static', value: 'loans' },
74
- * { type: 'param', value: 'id' },
75
- * { type: 'action', value: 'edit' }
76
- * ]
77
- */
78
- function parsePathPattern(pathPattern) {
79
- const segments = []
80
- const parts = pathPattern.split('/').filter(Boolean)
81
-
82
- for (const part of parts) {
83
- if (part.startsWith(':')) {
84
- // Param segment: :id, :bookId
85
- segments.push({ type: 'param', value: part.slice(1) })
86
- } else if (ACTION_SEGMENTS.includes(part.toLowerCase())) {
87
- // Action segment: edit, create, show
88
- segments.push({ type: 'action', value: part })
89
- } else {
90
- // Static segment: books, loans
91
- segments.push({ type: 'static', value: part })
92
- }
93
- }
94
-
95
- return segments
96
- }
97
-
98
49
  /**
99
- * Build navigation chain from parsed path segments + route meta
100
- *
101
- * Uses route meta to know which entity each static segment represents.
102
- * The meta.parent chain declares the entity hierarchy.
50
+ * Convert semantic breadcrumb to navigation chain format
51
+ * Maps semantic kinds to chain types for compatibility
103
52
  */
104
- function buildNavChain(pathSegments, routeMeta, routeParams) {
53
+ function semanticToNavChain(semantic) {
105
54
  const chain = []
106
- const meta = routeMeta || {}
107
-
108
- // Collect all entities in the hierarchy (parent chain + current)
109
- const entityHierarchy = []
110
-
111
- // Build parent chain (oldest ancestor first)
112
- function collectParents(parentConfig) {
113
- if (!parentConfig) return
114
- const parentManager = getManager(parentConfig.entity)
115
- if (!parentManager) return
116
-
117
- // Check if this parent has its own parent
118
- const parentRoute = router.getRoutes().find(r =>
119
- r.name === `${parentManager.routePrefix}-edit`
120
- )
121
- if (parentRoute?.meta?.parent) {
122
- collectParents(parentRoute.meta.parent)
123
- }
124
-
125
- entityHierarchy.push({
126
- entity: parentConfig.entity,
127
- manager: parentManager,
128
- idParam: parentConfig.param
129
- })
130
- }
131
-
132
- collectParents(meta.parent)
133
55
 
134
- // Add current entity
135
- if (meta.entity) {
136
- const currentManager = getManager(meta.entity)
137
- if (currentManager) {
138
- entityHierarchy.push({
139
- entity: meta.entity,
140
- manager: currentManager,
141
- idParam: 'id' // Standard param for current entity
56
+ for (const item of semantic) {
57
+ if (item.kind === 'entity-list') {
58
+ const manager = getManager(item.entity)
59
+ chain.push({
60
+ type: 'list',
61
+ entity: item.entity,
62
+ manager,
63
+ label: manager?.labelPlural || item.entity,
64
+ routeName: manager?.routePrefix || item.entity.slice(0, -1)
142
65
  })
143
- }
144
- }
145
-
146
- // Now build chain from hierarchy
147
- for (const { entity, manager, idParam } of entityHierarchy) {
148
- const entityId = routeParams[idParam]
149
-
150
- // Add list segment
151
- chain.push({
152
- type: 'list',
153
- entity,
154
- manager,
155
- label: manager.labelPlural || manager.name,
156
- routeName: manager.routePrefix
157
- })
158
-
159
- // Add item segment if we have an ID for this entity
160
- if (entityId) {
66
+ } else if (item.kind.startsWith('entity-') && item.id) {
67
+ // entity-show, entity-edit, entity-delete
68
+ const manager = getManager(item.entity)
161
69
  chain.push({
162
70
  type: 'item',
163
- entity,
71
+ entity: item.entity,
164
72
  manager,
165
- id: entityId,
166
- routeName: `${manager.routePrefix}-edit`
73
+ id: item.id,
74
+ routeName: manager ? `${manager.routePrefix}-edit` : null
75
+ })
76
+ } else if (item.kind === 'entity-create') {
77
+ // Create page - treat as special list
78
+ const manager = getManager(item.entity)
79
+ chain.push({
80
+ type: 'create',
81
+ entity: item.entity,
82
+ manager,
83
+ label: 'Create',
84
+ routeName: manager ? `${manager.routePrefix}-create` : null
85
+ })
86
+ } else if (item.kind === 'route') {
87
+ // Generic route (like /books/stats)
88
+ // Use label from semantic item if provided (custom breadcrumb), else lookup route
89
+ const routeRecord = router.getRoutes().find(r => r.name === item.route)
90
+ chain.push({
91
+ type: 'route',
92
+ route: item.route,
93
+ label: item.label || routeRecord?.meta?.navLabel || routeRecord?.meta?.title || item.route,
94
+ routeName: item.route
167
95
  })
168
- }
169
- }
170
-
171
- // Handle child-list case: when on a child route without :id
172
- // The last segment is a child-list, not a regular list
173
- if (meta.parent && !routeParams.id && chain.length > 0) {
174
- const lastSegment = chain[chain.length - 1]
175
- if (lastSegment.type === 'list') {
176
- lastSegment.type = 'child-list'
177
- lastSegment.navLabel = meta.navLabel
178
96
  }
179
97
  }
180
98
 
@@ -186,23 +104,10 @@ export function useNavContext(options = {}) {
186
104
  // ============================================================================
187
105
 
188
106
  /**
189
- * Parsed path segments from current route
190
- */
191
- const pathSegments = computed(() => {
192
- // Get the matched route's path pattern
193
- const matched = route.matched
194
- if (!matched.length) return []
195
-
196
- // Use the last matched route's full path
197
- const lastMatch = matched[matched.length - 1]
198
- return parsePathPattern(lastMatch.path)
199
- })
200
-
201
- /**
202
- * Navigation chain built from path analysis
107
+ * Navigation chain built from semantic breadcrumb
203
108
  */
204
109
  const navChain = computed(() => {
205
- return buildNavChain(pathSegments.value, route.meta, route.params)
110
+ return semanticToNavChain(semanticBreadcrumb.value)
206
111
  })
207
112
 
208
113
  // ============================================================================
@@ -293,15 +198,25 @@ export function useNavContext(options = {}) {
293
198
  if (segment.type === 'list') {
294
199
  items.push({
295
200
  label: segment.label,
296
- to: { name: segment.routeName }
201
+ to: isLast ? null : (segment.routeName && routeExists(segment.routeName) ? { name: segment.routeName } : null)
297
202
  })
298
203
  } else if (segment.type === 'item') {
299
204
  const data = chainData.value.get(i)
300
- const label = data ? segment.manager.getEntityLabel(data) : '...'
205
+ const label = data && segment.manager ? segment.manager.getEntityLabel(data) : '...'
301
206
 
302
207
  items.push({
303
208
  label,
304
- to: isLast ? null : { name: segment.routeName, params: { id: segment.id } }
209
+ to: isLast ? null : (segment.routeName && routeExists(segment.routeName) ? { name: segment.routeName, params: { id: segment.id } } : null)
210
+ })
211
+ } else if (segment.type === 'create') {
212
+ items.push({
213
+ label: segment.label
214
+ })
215
+ } else if (segment.type === 'route') {
216
+ // Generic route (e.g., /books/stats)
217
+ items.push({
218
+ label: segment.label,
219
+ to: isLast ? null : (segment.routeName && routeExists(segment.routeName) ? { name: segment.routeName } : null)
305
220
  })
306
221
  } else if (segment.type === 'child-list') {
307
222
  items.push({
@@ -383,8 +298,8 @@ export function useNavContext(options = {}) {
383
298
 
384
299
  return {
385
300
  // Analysis
386
- pathSegments,
387
301
  navChain,
302
+ semanticBreadcrumb,
388
303
 
389
304
  // Data
390
305
  entityData,
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { computed, inject, ref, onMounted } from 'vue'
13
13
  import { useRoute, useRouter } from 'vue-router'
14
- import { getNavSections, isRouteInFamily, alterMenuSections, isMenuAltered } from '../module/moduleRegistry'
14
+ import { getNavSections, alterMenuSections, isMenuAltered } from '../module/moduleRegistry'
15
+ import { useSemanticBreadcrumb } from './useSemanticBreadcrumb'
15
16
 
16
17
  /**
17
18
  * Navigation composable
@@ -31,6 +32,9 @@ export function useNavigation() {
31
32
  const orchestrator = inject('qdadmOrchestrator', null)
32
33
  const hooks = inject('qdadmHooks', null)
33
34
 
35
+ // Semantic breadcrumb for entity-based active detection
36
+ const { breadcrumb } = useSemanticBreadcrumb()
37
+
34
38
  // Track whether menu:alter has completed
35
39
  const isReady = ref(isMenuAltered())
36
40
 
@@ -74,17 +78,29 @@ export function useNavigation() {
74
78
 
75
79
  /**
76
80
  * Check if a nav item is currently active
81
+ * Uses semantic breadcrumb only - no route segment deduction
77
82
  */
78
83
  function isNavActive(item) {
79
- const currentRouteName = route.name
80
-
81
84
  // Exact match mode
82
85
  if (item.exact) {
83
- return currentRouteName === item.route
86
+ return route.name === item.route
87
+ }
88
+
89
+ // Check semantic breadcrumb for match
90
+ const bc = breadcrumb.value
91
+
92
+ // Entity-based menu item: match ONLY by entity (don't fall through)
93
+ if (item.entity) {
94
+ const firstEntityItem = bc.find(b => b.kind.startsWith('entity-'))
95
+ return firstEntityItem ? firstEntityItem.entity === item.entity : false
96
+ }
97
+
98
+ // Route-based menu item: check if route is in breadcrumb
99
+ if (item.route) {
100
+ return bc.some(b => b.kind === 'route' && b.route === item.route)
84
101
  }
85
102
 
86
- // Use registry's family detection
87
- return isRouteInFamily(currentRouteName, item.route)
103
+ return false
88
104
  }
89
105
 
90
106
  /**
@@ -0,0 +1,182 @@
1
+ import { computed } from 'vue'
2
+ import { useRoute, useRouter } from 'vue-router'
3
+
4
+ /**
5
+ * Action segments mapping to entity kinds
6
+ */
7
+ const ACTION_MAP = {
8
+ edit: 'entity-edit',
9
+ create: 'entity-create',
10
+ new: 'entity-create',
11
+ show: 'entity-show',
12
+ view: 'entity-show',
13
+ delete: 'entity-delete'
14
+ }
15
+
16
+ /**
17
+ * Compute semantic breadcrumb from route path and routes list
18
+ *
19
+ * Pure function that can be used both in Vue composables and outside components.
20
+ *
21
+ * If the matched route has `meta.breadcrumb`, that array is used directly.
22
+ * This allows routes to define a custom breadcrumb structure.
23
+ *
24
+ * @param {string} path - Current route path (e.g., '/books/1/edit')
25
+ * @param {Array} routes - List of all registered routes from router.getRoutes()
26
+ * @param {object} [currentRoute] - Current route object (optional, for meta.breadcrumb support)
27
+ * @returns {Array} Semantic breadcrumb items
28
+ */
29
+ export function computeSemanticBreadcrumb(path, routes, currentRoute = null) {
30
+ if (!path) return []
31
+
32
+ // Check if route defines a custom breadcrumb
33
+ if (currentRoute?.meta?.breadcrumb) {
34
+ return currentRoute.meta.breadcrumb
35
+ }
36
+
37
+ const items = []
38
+
39
+ // Filter out catch-all and not-found routes
40
+ const validRoutes = routes.filter(r =>
41
+ r.name !== 'not-found' &&
42
+ !r.path.includes('*') &&
43
+ !r.path.includes(':pathMatch')
44
+ )
45
+
46
+ // Note: Home is NOT included in semantic breadcrumb
47
+ // It's a display concern - add it in useBreadcrumb if needed
48
+
49
+ const segments = path.split('/').filter(Boolean)
50
+ let currentPath = ''
51
+ let lastEntity = null
52
+ let pendingId = null // Track unmatched segments that could be IDs
53
+
54
+ for (let i = 0; i < segments.length; i++) {
55
+ const segment = segments[i]
56
+ const nextSegment = segments[i + 1]
57
+ currentPath += `/${segment}`
58
+
59
+ // Check if this is an action segment
60
+ const actionKind = ACTION_MAP[segment.toLowerCase()]
61
+ if (actionKind && lastEntity) {
62
+ // If we have a pending ID, add a new entity item with action kind
63
+ if (pendingId) {
64
+ items.push({ kind: actionKind, entity: lastEntity, id: pendingId })
65
+ pendingId = null
66
+ } else {
67
+ // No pending ID - update last item's kind (e.g., /books/create)
68
+ const lastItem = items[items.length - 1]
69
+ if (lastItem && lastItem.kind.startsWith('entity-') && !lastItem.id) {
70
+ lastItem.kind = actionKind
71
+ }
72
+ }
73
+ continue
74
+ }
75
+
76
+ // Find matching route
77
+ const matchedRoute = validRoutes.find(r => {
78
+ if (r.path === currentPath) return true
79
+ const routeSegs = r.path.split('/').filter(Boolean)
80
+ const pathSegs = currentPath.split('/').filter(Boolean)
81
+ if (routeSegs.length !== pathSegs.length) return false
82
+ return routeSegs.every((rs, idx) => rs.startsWith(':') || rs === pathSegs[idx])
83
+ })
84
+
85
+ if (!matchedRoute) {
86
+ // No route found - this segment could be an ID (e.g., /books/1 when route is /books/:id/edit)
87
+ if (lastEntity) {
88
+ pendingId = segment
89
+ }
90
+ continue
91
+ }
92
+
93
+ const entity = matchedRoute.meta?.entity || null
94
+ const isParam = matchedRoute.path.split('/').filter(Boolean).some((s, idx) => {
95
+ const pathSegs = currentPath.split('/').filter(Boolean)
96
+ return s.startsWith(':') && idx === pathSegs.length - 1
97
+ })
98
+
99
+ if (entity) {
100
+ lastEntity = entity
101
+ pendingId = null // Clear pending ID when we match a route
102
+ if (isParam) {
103
+ // Entity instance - get param value (the ID)
104
+ const paramValue = segment
105
+ // Determine kind based on next segment or route name
106
+ let kind = 'entity-show'
107
+ if (nextSegment && ACTION_MAP[nextSegment.toLowerCase()]) {
108
+ kind = ACTION_MAP[nextSegment.toLowerCase()]
109
+ } else if (matchedRoute.name?.includes('edit')) {
110
+ kind = 'entity-edit'
111
+ }
112
+ items.push({ kind, entity, id: paramValue })
113
+ } else {
114
+ // Entity list
115
+ items.push({ kind: 'entity-list', entity })
116
+ }
117
+ } else {
118
+ // Generic route
119
+ items.push({ kind: 'route', route: matchedRoute.name || segment })
120
+ }
121
+ }
122
+
123
+ return items
124
+ }
125
+
126
+ /**
127
+ * useSemanticBreadcrumb - Vue composable for semantic breadcrumb
128
+ *
129
+ * Returns semantic objects per level - adapters resolve labels/paths for display.
130
+ *
131
+ * Kinds:
132
+ * - route: Generic route (e.g., home, dashboard)
133
+ * - entity-list: Entity collection (e.g., /books)
134
+ * - entity-show: Entity instance view (e.g., /books/1)
135
+ * - entity-edit: Entity instance edit (e.g., /books/1/edit)
136
+ * - entity-create: Entity creation (e.g., /books/create)
137
+ *
138
+ * @example
139
+ * const { breadcrumb } = useSemanticBreadcrumb()
140
+ * // For /books/1/edit returns:
141
+ * // [
142
+ * // { kind: 'route', route: 'home' },
143
+ * // { kind: 'entity-list', entity: 'books' },
144
+ * // { kind: 'entity-edit', entity: 'books', id: '1' }
145
+ * // ]
146
+ */
147
+ export function useSemanticBreadcrumb() {
148
+ const route = useRoute()
149
+ const router = useRouter()
150
+
151
+ const breadcrumb = computed(() => {
152
+ return computeSemanticBreadcrumb(route.path, router.getRoutes(), route)
153
+ })
154
+
155
+ /**
156
+ * Check if an entity is active (present in current breadcrumb)
157
+ * Useful for menu highlighting
158
+ *
159
+ * @param {string} entity - Entity name to check
160
+ * @returns {boolean}
161
+ */
162
+ function isEntityActive(entity) {
163
+ return breadcrumb.value.some(item =>
164
+ item.kind.startsWith('entity-') && item.entity === entity
165
+ )
166
+ }
167
+
168
+ /**
169
+ * Get the active entity from breadcrumb (first entity found)
170
+ * @returns {string|null}
171
+ */
172
+ const activeEntity = computed(() => {
173
+ const entityItem = breadcrumb.value.find(item => item.kind.startsWith('entity-'))
174
+ return entityItem?.entity || null
175
+ })
176
+
177
+ return {
178
+ breadcrumb,
179
+ isEntityActive,
180
+ activeEntity
181
+ }
182
+ }
@@ -27,6 +27,7 @@ import { ToastCollector } from './ToastCollector.js'
27
27
  import { ZonesCollector } from './ZonesCollector.js'
28
28
  import { AuthCollector } from './AuthCollector.js'
29
29
  import { EntitiesCollector } from './EntitiesCollector.js'
30
+ import { RouterCollector } from './RouterCollector.js'
30
31
  import DebugBar from './components/DebugBar.vue'
31
32
 
32
33
  /**
@@ -92,6 +93,7 @@ export class DebugModule extends Module {
92
93
  * @param {boolean} [options.zonesCollector=true] - Include ZonesCollector
93
94
  * @param {boolean} [options.authCollector=true] - Include AuthCollector
94
95
  * @param {boolean} [options.entitiesCollector=true] - Include EntitiesCollector
96
+ * @param {boolean} [options.routerCollector=true] - Include RouterCollector
95
97
  */
96
98
  constructor(options = {}) {
97
99
  super(options)
@@ -155,6 +157,10 @@ export class DebugModule extends Module {
155
157
  this._bridge.addCollector(new EntitiesCollector(collectorOptions))
156
158
  }
157
159
 
160
+ if (this.options.routerCollector !== false) {
161
+ this._bridge.addCollector(new RouterCollector(collectorOptions))
162
+ }
163
+
158
164
  // Install collectors with context
159
165
  this._bridge.install(ctx)
160
166