qdadm 0.51.8 → 0.52.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.51.8",
3
+ "version": "0.52.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ActiveStack - Reactive container for the active navigation stack
3
+ *
4
+ * Simple container rebuilt from route by useActiveStack.
5
+ * Each level: { entity, id, data, label }
6
+ *
7
+ * @example
8
+ * // Route /books/123/loans → Stack:
9
+ * [
10
+ * { entity: 'books', id: '123', data: null, label: 'Books' },
11
+ * { entity: 'loans', id: null, data: null, label: 'Loans' }
12
+ * ]
13
+ */
14
+
15
+ import { ref, computed } from 'vue'
16
+
17
+ export class ActiveStack {
18
+ constructor() {
19
+ this._stack = ref([])
20
+ }
21
+
22
+ /**
23
+ * Replace entire stack (called on route change)
24
+ */
25
+ set(levels) {
26
+ this._stack.value = levels
27
+ }
28
+
29
+ /**
30
+ * Update a level's data and label
31
+ */
32
+ updateLevel(index, data, label) {
33
+ if (index < 0 || index >= this._stack.value.length) return
34
+ const newStack = [...this._stack.value]
35
+ newStack[index] = { ...newStack[index], data, label }
36
+ this._stack.value = newStack
37
+ }
38
+
39
+ /**
40
+ * Find and update level by entity name
41
+ */
42
+ updateByEntity(entity, data, label) {
43
+ const index = this._stack.value.findIndex(l => l.entity === entity)
44
+ if (index !== -1) {
45
+ this.updateLevel(index, data, label)
46
+ }
47
+ }
48
+
49
+ clear() {
50
+ this._stack.value = []
51
+ }
52
+
53
+ // Computed accessors
54
+ get levels() {
55
+ return computed(() => this._stack.value)
56
+ }
57
+
58
+ get current() {
59
+ return computed(() => this._stack.value.at(-1) || null)
60
+ }
61
+
62
+ get parent() {
63
+ return computed(() => this._stack.value.at(-2) || null)
64
+ }
65
+
66
+ get root() {
67
+ return computed(() => this._stack.value[0] || null)
68
+ }
69
+
70
+ get depth() {
71
+ return computed(() => this._stack.value.length)
72
+ }
73
+ }
74
+
75
+ export default ActiveStack
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chain Module - Active navigation stack management
3
+ *
4
+ * Provides:
5
+ * - ActiveStack: Reactive container for current navigation stack
6
+ * - useActiveStack: Composable to build and access the stack
7
+ *
8
+ * @module chain
9
+ */
10
+
11
+ export { ActiveStack } from './ActiveStack.js'
12
+ export { useActiveStack } from './useActiveStack.js'
@@ -0,0 +1,135 @@
1
+ /**
2
+ * useActiveStack - Build navigation stack from route
3
+ *
4
+ * The route is the single source of truth:
5
+ * - route.meta.entity → current entity
6
+ * - route.meta.parent → parent chain config
7
+ * - route.params → entity IDs
8
+ *
9
+ * Stack is rebuilt on every route change. Data is set by pages when they load.
10
+ */
11
+
12
+ import { watch, inject, computed } from 'vue'
13
+ import { useRoute } from 'vue-router'
14
+
15
+ export function useActiveStack() {
16
+ const route = useRoute()
17
+ const activeStack = inject('qdadmActiveStack')
18
+ const orchestrator = inject('qdadmOrchestrator')
19
+
20
+ if (!activeStack) {
21
+ console.warn('[useActiveStack] ActiveStack not provided')
22
+ return createEmptyStack()
23
+ }
24
+
25
+ /**
26
+ * Build stack from current route
27
+ * Traverses route.meta.parent chain to build all levels
28
+ */
29
+ function rebuildStack() {
30
+ const entity = route.meta?.entity
31
+ if (!entity) {
32
+ activeStack.clear()
33
+ return
34
+ }
35
+
36
+ const levels = []
37
+ const manager = orchestrator?.get(entity)
38
+
39
+ // Build parent chain from route.meta.parent (traverse nested parents)
40
+ let parentConfig = route.meta?.parent
41
+ const parentLevels = []
42
+
43
+ while (parentConfig) {
44
+ const parentManager = orchestrator?.get(parentConfig.entity)
45
+ const id = route.params[parentConfig.param] || null
46
+
47
+ parentLevels.unshift({
48
+ entity: parentConfig.entity,
49
+ id,
50
+ data: null,
51
+ label: parentManager?.labelPlural || parentConfig.entity
52
+ })
53
+
54
+ // Traverse nested parent config (NOT manager.parent)
55
+ parentConfig = parentConfig.parent || null
56
+ }
57
+
58
+ levels.push(...parentLevels)
59
+
60
+ // Add current entity
61
+ const idField = manager?.idField || 'id'
62
+ const currentId = route.params[idField] || null
63
+
64
+ levels.push({
65
+ entity,
66
+ id: currentId,
67
+ data: null,
68
+ label: manager?.labelPlural || entity
69
+ })
70
+
71
+ activeStack.set(levels)
72
+ }
73
+
74
+ /**
75
+ * Set data for current level (called by useEntityItemPage/useForm)
76
+ */
77
+ function setCurrentData(data) {
78
+ const levels = activeStack.levels.value
79
+ if (levels.length === 0) return
80
+
81
+ const index = levels.length - 1
82
+ const level = levels[index]
83
+ const manager = orchestrator?.get(level.entity)
84
+ const label = manager?.getEntityLabel?.(data) || level.label
85
+
86
+ activeStack.updateLevel(index, data, label)
87
+ }
88
+
89
+ /**
90
+ * Set data for a level by entity name
91
+ */
92
+ function setEntityData(entity, data) {
93
+ const manager = orchestrator?.get(entity)
94
+ const label = manager?.getEntityLabel?.(data) || entity
95
+
96
+ activeStack.updateByEntity(entity, data, label)
97
+ }
98
+
99
+ // Rebuild stack on route change
100
+ watch(() => route.fullPath, rebuildStack, { immediate: true })
101
+
102
+ return {
103
+ // Computed refs from ActiveStack
104
+ levels: activeStack.levels,
105
+ current: activeStack.current,
106
+ parent: activeStack.parent,
107
+ root: activeStack.root,
108
+ depth: activeStack.depth,
109
+
110
+ // Data setters (called by pages)
111
+ setCurrentData,
112
+ setEntityData,
113
+
114
+ // Manual rebuild
115
+ rebuild: rebuildStack
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Fallback when ActiveStack not available
121
+ */
122
+ function createEmptyStack() {
123
+ return {
124
+ levels: computed(() => []),
125
+ current: computed(() => null),
126
+ parent: computed(() => null),
127
+ root: computed(() => null),
128
+ depth: computed(() => 0),
129
+ setCurrentData: () => {},
130
+ setEntityData: () => {},
131
+ rebuild: () => {}
132
+ }
133
+ }
134
+
135
+ export default useActiveStack
@@ -159,35 +159,16 @@ function handleLogout() {
159
159
  const slots = useSlots()
160
160
  const hasSlotContent = computed(() => !!slots.default)
161
161
 
162
- // Breadcrumb entity data - multi-level support for parent/child entities
163
- // Map: level -> entityData (level 1 = parent, level 2 = child, etc.)
164
- const breadcrumbEntities = ref(new Map())
165
-
166
- /**
167
- * Set entity data for breadcrumb at a specific level
168
- * @param {object} data - Entity data
169
- * @param {number} level - Breadcrumb level (1 = main entity, 2 = child, etc.)
170
- */
171
- function setBreadcrumbEntity(data, level = 1) {
172
- const newMap = new Map(breadcrumbEntities.value)
173
- newMap.set(level, data)
174
- breadcrumbEntities.value = newMap
175
- }
176
-
177
- provide('qdadmSetBreadcrumbEntity', setBreadcrumbEntity)
178
- provide('qdadmBreadcrumbEntities', breadcrumbEntities)
179
-
180
- // Clear entity data and overrides on route change (before new page mounts)
162
+ // Clear overrides on route change (before new page mounts)
181
163
  // This ensures list pages get default breadcrumb, detail pages can override via PageNav
182
164
  watch(() => route.fullPath, () => {
183
- breadcrumbEntities.value = new Map()
184
165
  breadcrumbOverride.value = null
185
166
  navlinksOverride.value = null
186
167
  })
187
168
 
188
169
  // Navigation context (breadcrumb + navlinks from route config)
189
- // Pass breadcrumbEntities directly since we're in the same component that provides it
190
- const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext({ breadcrumbEntities })
170
+ // Entity data comes from activeStack (populated by useEntityItemPage/useForm)
171
+ const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext()
191
172
 
192
173
  // Allow child pages to override breadcrumb/navlinks via provide/inject
193
174
  const breadcrumbOverride = ref(null)
@@ -1,150 +1,49 @@
1
1
  <script setup>
2
2
  /**
3
- * PageNav - Route-aware navigation provider for breadcrumb + navlinks
3
+ * PageNav - Navigation provider for navlinks (child/sibling routes)
4
4
  *
5
- * This component doesn't render anything visible. Instead, it provides
6
- * breadcrumb items and navlinks to AppLayout via provide/inject.
5
+ * This component provides navlinks to AppLayout via provide/inject.
6
+ * Breadcrumb is now handled automatically by useNavContext + activeStack.
7
7
  *
8
8
  * Layout (rendered in AppLayout):
9
9
  * Books > "Dune" Details | Loans | Reviews
10
- * ↑ breadcrumb (left) ↑ navlinks (right)
10
+ * ↑ breadcrumb (from useNavContext) ↑ navlinks (from PageNav)
11
11
  *
12
12
  * Auto-detects from current route:
13
- * - Breadcrumb: parent chain from route.meta.parent
14
- * - Navlinks: sibling routes (same parent entity + param)
13
+ * - Navlinks: sibling routes (same parent entity + param) or children routes
15
14
  *
16
15
  * Props:
17
- * - entity: Current entity data (for dynamic labels in breadcrumb)
18
- * - parentEntity: Parent entity data (for parent label in breadcrumb)
16
+ * - showDetailsLink: Show "Details" link in navlinks (default: false)
19
17
  */
20
- import { computed, ref, watch, inject } from 'vue'
21
- import { useRoute, useRouter } from 'vue-router'
18
+ import { computed, watch, inject } from 'vue'
19
+ import { useRoute } from 'vue-router'
22
20
  import { getSiblingRoutes, getChildRoutes } from '../../module/moduleRegistry.js'
23
21
  import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
24
22
 
25
- // Inject override refs from AppLayout
26
- const breadcrumbOverride = inject('qdadmBreadcrumbOverride', null)
23
+ // Inject override ref from AppLayout
27
24
  const navlinksOverride = inject('qdadmNavlinksOverride', null)
28
- const homeRouteName = inject('qdadmHomeRoute', null)
29
- // Entity data set by useEntityItemPage via setBreadcrumbEntity
30
- const breadcrumbEntities = inject('qdadmBreadcrumbEntities', null)
31
25
 
32
26
  const props = defineProps({
33
- entity: { type: Object, default: null },
34
- parentEntity: { type: Object, default: null },
35
27
  // Show "Details" link in navlinks (default: false since breadcrumb shows current page)
36
28
  showDetailsLink: { type: Boolean, default: false }
37
29
  })
38
30
 
39
31
  const route = useRoute()
40
- const router = useRouter()
41
32
  const { getManager } = useOrchestrator()
42
33
 
43
34
  // Parent config from route meta
44
35
  const parentConfig = computed(() => route.meta?.parent)
45
36
 
46
- // Parent entity data (loaded if not provided via prop)
47
- const parentData = ref(props.parentEntity)
48
-
49
- // Load parent entity if needed
50
- watch(() => [parentConfig.value, route.params], async () => {
51
- if (!parentConfig.value || props.parentEntity) return
52
-
53
- const { entity: parentEntityName, param } = parentConfig.value
54
- const parentId = route.params[param]
55
-
56
- if (parentEntityName && parentId) {
57
- try {
58
- const manager = getManager(parentEntityName)
59
- if (manager) {
60
- parentData.value = await manager.get(parentId)
61
- }
62
- } catch (e) {
63
- console.warn('[PageNav] Failed to load parent entity:', e)
64
- }
65
- }
66
- }, { immediate: true })
67
-
68
- // Home breadcrumb item
69
- const homeItem = computed(() => {
70
- if (!homeRouteName) return null
71
- const routes = router.getRoutes()
72
- if (!routes.some(r => r.name === homeRouteName)) return null
73
- const label = homeRouteName === 'dashboard' ? 'Dashboard' : 'Home'
74
- return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
75
- })
76
-
77
- // Build breadcrumb items
78
- const breadcrumbItems = computed(() => {
79
- const items = []
80
-
81
- // Always start with Home if configured
82
- if (homeItem.value) {
83
- items.push(homeItem.value)
84
- }
85
-
86
- if (!parentConfig.value) {
87
- // No parent - use simple breadcrumb from entity
88
- const entityName = route.meta?.entity
89
- if (entityName) {
90
- const manager = getManager(entityName)
91
- if (manager) {
92
- // Entity list link
93
- items.push({
94
- label: manager.labelPlural || manager.name,
95
- to: { name: manager.routePrefix }
96
- })
97
-
98
- // If on detail page (has :id param), add current entity item
99
- const entityId = route.params.id
100
- if (entityId) {
101
- // Get entity data from props or from breadcrumbEntities (set by useEntityItemPage)
102
- const entityData = props.entity || breadcrumbEntities?.value?.get(1)
103
- const entityLabel = entityData
104
- ? manager.getEntityLabel(entityData)
105
- : '...'
106
- items.push({ label: entityLabel })
107
- }
108
- }
109
- }
110
- return items
111
- }
112
-
113
- // Has parent - build parent chain
114
- const { entity: parentEntityName, param, itemRoute } = parentConfig.value
115
- const parentId = route.params[param]
116
- const parentManager = getManager(parentEntityName)
117
-
118
- if (parentManager) {
119
- // Parent list
120
- items.push({
121
- label: parentManager.labelPlural || parentManager.name,
122
- to: { name: parentManager.routePrefix }
123
- })
124
-
125
- // Parent item (with label from data)
126
- // Prefer breadcrumbEntities (set by useEntityItemPage) over local parentData
127
- const parentEntityData = breadcrumbEntities?.value?.get(1) || parentData.value
128
- const parentLabel = parentEntityData
129
- ? parentManager.getEntityLabel(parentEntityData)
130
- : '...'
131
- const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
132
- const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
133
-
134
- items.push({
135
- label: parentLabel,
136
- to: { name: parentRouteName, params: { id: parentId } }
137
- })
138
- }
139
-
140
- // Current entity (last item, no link)
141
- const currentLabel = route.meta?.navLabel
142
- if (currentLabel) {
143
- items.push({ label: currentLabel })
144
- }
145
-
146
- return items
147
- })
37
+ /**
38
+ * Get default item route for an entity manager
39
+ * - Read-only entities: use -show suffix
40
+ * - Editable entities: use -edit suffix
41
+ */
42
+ function getDefaultItemRoute(manager) {
43
+ if (!manager) return null
44
+ const suffix = manager.readOnly ? '-show' : '-edit'
45
+ return `${manager.routePrefix}${suffix}`
46
+ }
148
47
 
149
48
  // Sibling navlinks (routes with same parent)
150
49
  const siblingNavlinks = computed(() => {
@@ -194,13 +93,15 @@ const childNavlinks = computed(() => {
194
93
 
195
94
  if (navRoutes.length === 0) return []
196
95
 
197
- const entityId = route.params.id
96
+ // Get current entity's manager to determine idField
97
+ const currentManager = getManager(entityName)
98
+ const entityId = route.params[currentManager?.idField || 'id']
198
99
 
199
100
  // Build navlinks to child routes
200
101
  return navRoutes.map(childRoute => {
201
102
  const childManager = childRoute.meta?.entity ? getManager(childRoute.meta.entity) : null
202
103
  const label = childRoute.meta?.navLabel || childManager?.labelPlural || childRoute.name
203
- const parentParam = childRoute.meta?.parent?.param || 'id'
104
+ const parentParam = childRoute.meta?.parent?.param || currentManager?.idField || 'id'
204
105
 
205
106
  return {
206
107
  label,
@@ -210,7 +111,7 @@ const childNavlinks = computed(() => {
210
111
  })
211
112
  })
212
113
 
213
- // Combined navlinks with "Details" link
114
+ // Combined navlinks with optional "Details" link
214
115
  const allNavlinks = computed(() => {
215
116
  // Case 1: On a child route - show siblings + optional Details link to parent
216
117
  if (parentConfig.value) {
@@ -218,19 +119,18 @@ const allNavlinks = computed(() => {
218
119
  const parentId = route.params[param]
219
120
  const parentManager = getManager(parentEntityName)
220
121
 
221
- if (!parentManager) return siblingNavlinks.value
122
+ // Guard: need valid manager and parentId to build links
123
+ if (!parentManager || !parentId) return []
222
124
 
223
125
  // Details link is optional since breadcrumb already shows parent
224
126
  if (props.showDetailsLink) {
225
- const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
226
- const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
127
+ const parentRouteName = itemRoute || getDefaultItemRoute(parentManager)
227
128
  const isOnParentRoute = route.name === parentRouteName
228
129
 
229
130
  // Details link to parent item page
230
- // CONVENTION: Entity item routes MUST use :id as param name (e.g., /books/:id)
231
131
  const detailsLink = {
232
132
  label: 'Details',
233
- to: { name: parentRouteName, params: { id: parentId } },
133
+ to: { name: parentRouteName, params: { [parentManager.idField]: parentId } },
234
134
  active: isOnParentRoute
235
135
  }
236
136
 
@@ -257,26 +157,14 @@ const allNavlinks = computed(() => {
257
157
  return []
258
158
  })
259
159
 
260
- // Sync breadcrumb and navlinks to AppLayout via provide/inject
261
- // Watch computed values + route changes + entity data to ensure updates
262
- watch([breadcrumbItems, () => route.fullPath, breadcrumbEntities], ([items]) => {
263
- if (breadcrumbOverride) {
264
- breadcrumbOverride.value = items
265
- }
266
- }, { immediate: true, deep: true })
267
-
160
+ // Sync navlinks to AppLayout via provide/inject
268
161
  watch([allNavlinks, () => route.fullPath], ([links]) => {
269
162
  if (navlinksOverride) {
270
163
  navlinksOverride.value = links
271
164
  }
272
165
  }, { immediate: true })
273
-
274
- // Note: We intentionally do NOT clear overrides in onUnmounted.
275
- // When navigating between routes, the new PageNav's watch will overwrite the values.
276
- // Clearing in onUnmounted causes a race condition where the old PageNav clears
277
- // AFTER the new PageNav has already set its values.
278
166
  </script>
279
167
 
280
168
  <template>
281
- <!-- PageNav provides data to AppLayout via inject, renders nothing -->
169
+ <!-- PageNav provides navlinks to AppLayout via inject, renders nothing -->
282
170
  </template>
@@ -13,7 +13,6 @@ 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'
16
- export { useCurrentEntity } from './useCurrentEntity'
17
16
  export { useEntityItemPage } from './useEntityItemPage'
18
17
  export { useNavContext } from './useNavContext'
19
18
  export { useNavigation } from './useNavigation'
@@ -1,45 +1,40 @@
1
1
  /**
2
- * useCurrentEntity - Share page entity data with navigation context (breadcrumb)
2
+ * @deprecated Use useActiveStack() instead
3
3
  *
4
- * When a page loads an entity, it calls setBreadcrumbEntity() to share
5
- * the data with AppLayout for breadcrumb display.
4
+ * Legacy composable for setting breadcrumb entity data.
5
+ * This has been replaced by the activeStack system.
6
6
  *
7
- * Usage in a detail page:
7
+ * Migration:
8
8
  * ```js
9
+ * // Before (deprecated)
9
10
  * const { setBreadcrumbEntity } = useCurrentEntity()
11
+ * setBreadcrumbEntity(data)
10
12
  *
11
- * async function loadProduct() {
12
- * product.value = await productsManager.get(productId)
13
- * setBreadcrumbEntity(product.value) // Level 1 (main entity)
14
- * }
15
- * ```
16
- *
17
- * For nested routes with parent/child entities:
18
- * ```js
19
- * // Parent page loaded first
20
- * setBreadcrumbEntity(book, 1) // Level 1: the book
21
- *
22
- * // Child page
23
- * setBreadcrumbEntity(loan, 2) // Level 2: the loan under the book
13
+ * // After
14
+ * const stack = useActiveStack()
15
+ * stack.setCurrentData(data)
24
16
  * ```
25
17
  */
26
- import { inject } from 'vue'
18
+ import { useActiveStack } from '../chain/useActiveStack.js'
27
19
 
28
20
  /**
29
- * Composable to share page entity data with breadcrumb
30
- * @returns {{ setBreadcrumbEntity: (data: object, level?: number) => void }}
21
+ * @deprecated Use useActiveStack() instead
31
22
  */
32
23
  export function useCurrentEntity() {
33
- const setBreadcrumbEntityFn = inject('qdadmSetBreadcrumbEntity', null)
24
+ console.warn('[qdadm] useCurrentEntity is deprecated. Use useActiveStack() instead.')
25
+
26
+ const stack = useActiveStack()
34
27
 
35
28
  /**
36
- * Set entity data for breadcrumb at a specific level
37
- * @param {object} data - Entity data
38
- * @param {number} level - Breadcrumb level (1 = main entity, 2 = child, etc.)
29
+ * @deprecated Use stack.setCurrentData() instead
39
30
  */
40
31
  function setBreadcrumbEntity(data, level = 1) {
41
- if (setBreadcrumbEntityFn) {
42
- setBreadcrumbEntityFn(data, level)
32
+ if (level === 1) {
33
+ stack.setCurrentData(data)
34
+ } else {
35
+ // For parent levels, use setEntityData with entity name
36
+ // But we don't have entity name here - just set current
37
+ stack.setCurrentData(data)
43
38
  }
44
39
  }
45
40
 
@@ -48,6 +43,6 @@ export function useCurrentEntity() {
48
43
 
49
44
  return {
50
45
  setBreadcrumbEntity,
51
- setCurrentEntity // deprecated alias
46
+ setCurrentEntity
52
47
  }
53
48
  }
@@ -121,15 +121,14 @@ export function useEntityItemFormPage(config = {}) {
121
121
  const confirm = useConfirm()
122
122
 
123
123
  // Use useEntityItemPage for common infrastructure
124
- // (orchestrator, manager, entityId, provide('mainEntity'), breadcrumb)
124
+ // (orchestrator, manager, entityId, provide('mainEntity'), stack)
125
125
  const itemPage = useEntityItemPage({
126
126
  entity,
127
127
  loadOnMount: false, // Form controls its own loading
128
- breadcrumb: false, // Form calls setBreadcrumbEntity manually after transform
129
128
  getId
130
129
  })
131
130
 
132
- const { manager, orchestrator, entityId, setBreadcrumbEntity, getInitialDataWithParent, parentConfig, parentId, parentData, parentChain, getChainDepth } = itemPage
131
+ const { manager, orchestrator, entityId, getInitialDataWithParent, parentConfig, parentId, parentData, parentChain, getChainDepth, stack } = itemPage
133
132
 
134
133
  // Read config from manager with option overrides
135
134
  const entityName = config.entityName ?? manager.label
@@ -251,8 +250,8 @@ export function useEntityItemFormPage(config = {}) {
251
250
  originalData.value = deepClone(transformed)
252
251
  takeSnapshot()
253
252
 
254
- // Share with navigation context for breadcrumb
255
- setBreadcrumbEntity(transformed)
253
+ // Update active stack
254
+ stack.setCurrentData(transformed)
256
255
 
257
256
  if (onLoadSuccess) {
258
257
  await onLoadSuccess(transformed)
@@ -328,14 +327,14 @@ export function useEntityItemFormPage(config = {}) {
328
327
  router.push(listRoute)
329
328
  } else if (!isEdit.value) {
330
329
  // "Create" without close: navigate to edit route for the created entity
331
- const createdId = responseData?.id || responseData?.uuid
330
+ const createdId = responseData?.[manager.idField]
332
331
  if (createdId) {
333
- // Build edit route: replace 'create' suffix with ':id/edit'
332
+ // Build edit route: replace 'create' suffix with 'edit'
334
333
  const currentRouteName = route.name || ''
335
334
  const editRouteName = currentRouteName.replace(/(-create|-new)$/, '-edit')
336
335
  router.push({
337
336
  name: editRouteName,
338
- params: { ...route.params, id: createdId }
337
+ params: { ...route.params, [manager.idField]: createdId }
339
338
  })
340
339
  }
341
340
  }
@@ -1290,7 +1289,10 @@ export function useEntityItemFormPage(config = {}) {
1290
1289
 
1291
1290
  // FormPage integration
1292
1291
  props: formProps,
1293
- events: formEvents
1292
+ events: formEvents,
1293
+
1294
+ // Active navigation stack (unified context)
1295
+ stack
1294
1296
  }
1295
1297
 
1296
1298
  // Auto-generate fields from manager schema if enabled
@@ -4,7 +4,7 @@
4
4
  * Provides common functionality for pages that display a single entity:
5
5
  * - Entity loading by ID from route params
6
6
  * - Loading/error state management
7
- * - Breadcrumb integration (auto-calls setBreadcrumbEntity)
7
+ * - Active stack integration (auto-updates navigation context)
8
8
  * - Manager access for child composables
9
9
  * - **Parent chain auto-detection** from route.meta.parent
10
10
  *
@@ -53,19 +53,17 @@
53
53
  */
54
54
  import { ref, computed, onMounted, inject, provide } from 'vue'
55
55
  import { useRoute } from 'vue-router'
56
- import { useCurrentEntity } from './useCurrentEntity.js'
56
+ import { useActiveStack } from '../chain/useActiveStack.js'
57
57
 
58
58
  export function useEntityItemPage(config = {}) {
59
59
  const {
60
60
  entity,
61
61
  // Loading options
62
62
  loadOnMount = true,
63
- breadcrumb = true,
64
63
  // Parent chain options
65
64
  autoLoadParent = true, // Auto-load parent entity from route.meta.parent
66
- // ID extraction
65
+ // ID extraction (custom function for special cases, otherwise uses manager.idField)
67
66
  getId = null,
68
- idParam = 'id',
69
67
  // Transform hook
70
68
  transformLoad = (data) => data,
71
69
  // Callbacks
@@ -91,8 +89,8 @@ export function useEntityItemPage(config = {}) {
91
89
  // Provide entity context for child components
92
90
  provide('mainEntity', entity)
93
91
 
94
- // Breadcrumb integration
95
- const { setBreadcrumbEntity } = useCurrentEntity()
92
+ // Active stack integration (unified navigation context)
93
+ const stack = useActiveStack()
96
94
 
97
95
  // ============ STATE ============
98
96
 
@@ -186,14 +184,8 @@ export function useEntityItemPage(config = {}) {
186
184
  if (data) {
187
185
  newChain.set(level, data)
188
186
 
189
- // Set breadcrumb at correct level
190
- // breadcrumbLevel = totalDepth - level (so immediate parent is totalDepth-1, grandparent is totalDepth-2, etc.)
191
- // Actually we want: root ancestor = 1, next = 2, ..., immediate parent = totalDepth-1, current = totalDepth
192
- // So breadcrumbLevel = totalDepth - level
193
- if (breadcrumb) {
194
- const breadcrumbLevel = totalDepth - level
195
- setBreadcrumbEntity(data, breadcrumbLevel)
196
- }
187
+ // Update active stack
188
+ stack.setEntityData(currentConfig.entity, data)
197
189
  }
198
190
 
199
191
  currentConfig = currentConfig.parent
@@ -233,11 +225,13 @@ export function useEntityItemPage(config = {}) {
233
225
 
234
226
  /**
235
227
  * Extract entity ID from route params
236
- * Supports custom getId function or param name
228
+ * Uses manager.idField as route param name (single source of truth)
229
+ * Supports custom getId function for special cases
237
230
  */
238
231
  const entityId = computed(() => {
239
232
  if (getId) return getId()
240
- return route.params[idParam] || route.params.id || route.params.key || null
233
+ // Use manager.idField as route param name, with fallbacks for common patterns
234
+ return route.params[manager.idField] || route.params.id || route.params.key || null
241
235
  })
242
236
 
243
237
  // ============ LOADING ============
@@ -267,12 +261,8 @@ export function useEntityItemPage(config = {}) {
267
261
  const transformed = transformLoad(responseData)
268
262
  data.value = transformed
269
263
 
270
- // Share with navigation context for breadcrumb
271
- // Level = depth of chain (1 if no parent, 2 if 1 parent, 3 if 2 parents, etc.)
272
- if (breadcrumb) {
273
- const level = getChainDepth()
274
- setBreadcrumbEntity(transformed, level)
275
- }
264
+ // Update active stack
265
+ stack.setCurrentData(transformed)
276
266
 
277
267
  if (onLoadSuccess) {
278
268
  await onLoadSuccess(transformed)
@@ -361,6 +351,8 @@ export function useEntityItemPage(config = {}) {
361
351
  // References (for parent composables)
362
352
  manager,
363
353
  orchestrator,
364
- setBreadcrumbEntity
354
+
355
+ // Active navigation stack (unified context)
356
+ stack
365
357
  }
366
358
  }
@@ -65,7 +65,7 @@
65
65
  import { ref, computed, watch, onMounted, inject, provide } from 'vue'
66
66
  import { useBareForm } from './useBareForm'
67
67
  import { useHooks } from './useHooks'
68
- import { useCurrentEntity } from './useCurrentEntity'
68
+ import { useActiveStack } from '../chain/useActiveStack.js'
69
69
  import { deepClone } from '../utils/transformers'
70
70
 
71
71
  export function useForm(options = {}) {
@@ -94,8 +94,8 @@ export function useForm(options = {}) {
94
94
  // Get HookRegistry for form:alter hook (optional, may not exist in tests)
95
95
  const hooks = useHooks()
96
96
 
97
- // Share entity data with navigation context (for breadcrumb)
98
- const { setCurrentEntity } = useCurrentEntity()
97
+ // Active stack for navigation context
98
+ const stack = useActiveStack()
99
99
 
100
100
  // Read config from manager with option overrides
101
101
  const routePrefix = options.routePrefix ?? manager.routePrefix
@@ -245,8 +245,8 @@ export function useForm(options = {}) {
245
245
  originalData.value = deepClone(data)
246
246
  takeSnapshot()
247
247
 
248
- // Share with navigation context for breadcrumb
249
- setCurrentEntity(data)
248
+ // Update active stack
249
+ stack.setCurrentData(data)
250
250
 
251
251
  // Invoke form:alter hooks after data is loaded
252
252
  await invokeFormAlterHook()
@@ -305,7 +305,7 @@ export function useForm(options = {}) {
305
305
  router.push({ name: routePrefix })
306
306
  } else if (!isEdit.value && redirectOnCreate) {
307
307
  // Redirect to edit mode after create
308
- const newId = responseData.id || responseData.key
308
+ const newId = responseData[manager.idField]
309
309
  router.replace({ name: `${routePrefix}-edit`, params: { id: newId } })
310
310
  }
311
311
 
@@ -1249,11 +1249,11 @@ export function useListPage(config = {}) {
1249
1249
  }
1250
1250
 
1251
1251
  function goToEdit(item) {
1252
- router.push({ name: `${routePrefix}-edit`, params: { id: item[resolvedDataKey] } })
1252
+ router.push({ name: `${routePrefix}-edit`, params: { [manager.idField]: item[resolvedDataKey] } })
1253
1253
  }
1254
1254
 
1255
1255
  function goToShow(item) {
1256
- router.push({ name: `${routePrefix}-show`, params: { id: item[resolvedDataKey] } })
1256
+ router.push({ name: `${routePrefix}-show`, params: { [manager.idField]: item[resolvedDataKey] } })
1257
1257
  }
1258
1258
 
1259
1259
  // ============ DELETE ============
@@ -2,7 +2,7 @@
2
2
  * useNavContext - Route-aware navigation context for breadcrumb and navlinks
3
3
  *
4
4
  * Uses semantic breadcrumb as the source of truth for navigation structure.
5
- * Semantic breadcrumb is computed from route path and registered routes.
5
+ * Entity data comes from activeStack (set by useEntityItemPage/useForm).
6
6
  *
7
7
  * Semantic breadcrumb kinds:
8
8
  * - entity-list: Entity collection (e.g., /books)
@@ -20,6 +20,7 @@ import { ref, computed, watch, inject } from 'vue'
20
20
  import { useRoute, useRouter } from 'vue-router'
21
21
  import { getSiblingRoutes } from '../module/moduleRegistry.js'
22
22
  import { useSemanticBreadcrumb } from './useSemanticBreadcrumb.js'
23
+ import { useActiveStack } from '../chain/useActiveStack.js'
23
24
 
24
25
  export function useNavContext(options = {}) {
25
26
  const route = useRoute()
@@ -32,11 +33,8 @@ export function useNavContext(options = {}) {
32
33
  const orchestrator = inject('qdadmOrchestrator', null)
33
34
  const homeRouteName = inject('qdadmHomeRoute', null)
34
35
 
35
- // Breadcrumb entity data - multi-level Map from AppLayout
36
- // Updated by pages via setBreadcrumbEntity(data, level)
37
- // Can be passed directly (for layout component that provides AND uses breadcrumb)
38
- // or injected from parent (for child pages)
39
- const breadcrumbEntities = options.breadcrumbEntities ?? inject('qdadmBreadcrumbEntities', null)
36
+ // Active stack for entity data (replaces legacy breadcrumbEntities)
37
+ const stack = useActiveStack()
40
38
 
41
39
  function getManager(entityName) {
42
40
  return orchestrator?.get(entityName)
@@ -124,57 +122,35 @@ export function useNavContext(options = {}) {
124
122
  })
125
123
 
126
124
  // ============================================================================
127
- // ENTITY DATA FETCHING
125
+ // ENTITY DATA FROM ACTIVESTACK
128
126
  // ============================================================================
129
127
 
130
128
  const chainData = ref(new Map()) // Map: chainIndex -> entityData
131
129
 
132
130
  /**
133
- * Fetch entity data for all 'item' segments in the chain
131
+ * Build chainData from activeStack levels
134
132
  *
135
- * For the LAST item (current entity):
136
- * - Uses entityData if provided by the page via setCurrentEntity()
137
- * - Does NOT fetch automatically - page is responsible for providing data
138
- * - Breadcrumb shows "..." until page provides the data
139
- *
140
- * For PARENT items: always fetches from manager
133
+ * ActiveStack is populated by useEntityItemPage/useForm when pages load.
134
+ * Each level in the stack corresponds to an entity in the navigation chain.
141
135
  */
142
- // Watch navChain and breadcrumbEntities to populate chainData
143
- // breadcrumbEntities is a ref to Map: level -> entityData (set by pages via setBreadcrumbEntity)
144
- // Note: watch ref directly, not () => ref.value, for proper reactivity tracking
145
- watch([navChain, breadcrumbEntities], async ([chain, entitiesMap]) => {
146
- // Build new Map (reassignment triggers Vue reactivity, Map.set() doesn't)
136
+ watch([navChain, stack.levels], ([chain, levels]) => {
137
+ // Build new Map (reassignment triggers Vue reactivity)
147
138
  const newChainData = new Map()
148
139
 
149
- // Count item segments to determine their level (1-based)
150
- let itemLevel = 0
140
+ // Count item segments to match with stack levels
141
+ let itemIndex = 0
151
142
 
152
143
  for (let i = 0; i < chain.length; i++) {
153
144
  const segment = chain[i]
154
145
  if (segment.type !== 'item') continue
155
146
 
156
- itemLevel++
157
- const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
158
-
159
- // Check if page provided data for this level via setBreadcrumbEntity
160
- const providedData = entitiesMap?.get(itemLevel)
161
- if (providedData) {
162
- newChainData.set(i, providedData)
163
- continue
147
+ // Find matching data in activeStack by entity name
148
+ const stackLevel = levels.find(l => l.entity === segment.entity && String(l.id) === String(segment.id))
149
+ if (stackLevel?.data) {
150
+ newChainData.set(i, stackLevel.data)
164
151
  }
165
152
 
166
- // For items without provided data:
167
- // - Last item: show "..." (page should call setBreadcrumbEntity)
168
- // - Parent items: fetch from manager
169
- if (!isLastItem) {
170
- try {
171
- const data = await segment.manager.get(segment.id)
172
- newChainData.set(i, data)
173
- } catch (e) {
174
- console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
175
- }
176
- }
177
- // Last item without data will show "..." in breadcrumb
153
+ itemIndex++
178
154
  }
179
155
 
180
156
  // Assign new Map to trigger reactivity
@@ -216,10 +192,11 @@ export function useNavContext(options = {}) {
216
192
  } else if (segment.type === 'item') {
217
193
  const data = chainData.value.get(i)
218
194
  const label = data && segment.manager ? segment.manager.getEntityLabel(data) : '...'
195
+ const idField = segment.manager?.idField || 'id'
219
196
 
220
197
  items.push({
221
198
  label,
222
- to: isLast ? null : (segment.routeName && routeExists(segment.routeName) ? { name: segment.routeName, params: { id: segment.id } } : null)
199
+ to: isLast ? null : (segment.routeName && routeExists(segment.routeName) ? { name: segment.routeName, params: { [idField]: segment.id } } : null)
223
200
  })
224
201
  } else if (segment.type === 'create') {
225
202
  items.push({
@@ -262,10 +239,10 @@ export function useNavContext(options = {}) {
262
239
  const parentRouteName = itemRoute || getDefaultItemRoute(parentManager)
263
240
  const isOnParent = route.name === parentRouteName
264
241
 
265
- // Details link
242
+ // Details link - use manager's idField for param name
266
243
  const links = [{
267
244
  label: 'Details',
268
- to: { name: parentRouteName, params: { id: parentId.value } },
245
+ to: { name: parentRouteName, params: { [parentManager.idField]: parentId.value } },
269
246
  active: isOnParent
270
247
  }]
271
248
 
@@ -72,7 +72,9 @@ export class EntityManager {
72
72
  parents = {}, // { book: { entity: 'books', foreignKey: 'book_id' } } - multi-parent support
73
73
  relations = {}, // { groups: { entity: 'groups', through: 'user_groups' } }
74
74
  // Auth adapter (for permission checks)
75
- authAdapter = null // AuthAdapter instance or null (uses PermissiveAuthAdapter)
75
+ authAdapter = null, // AuthAdapter instance or null (uses PermissiveAuthAdapter)
76
+ // Navigation config (for auto-generated menus)
77
+ nav = {} // { icon, section, weight, visible }
76
78
  } = options
77
79
 
78
80
  this.name = name
@@ -110,6 +112,9 @@ export class EntityManager {
110
112
  // Auth adapter (fallback to permissive if not provided)
111
113
  this._authAdapter = authAdapter
112
114
 
115
+ // Navigation config (for auto-generated menus via ChainRegistry)
116
+ this._nav = nav
117
+
113
118
  // HookRegistry reference for lifecycle hooks (set by Orchestrator)
114
119
  this._hooks = null
115
120
 
@@ -415,6 +420,16 @@ export class EntityManager {
415
420
  return entity[this._labelField] || null
416
421
  }
417
422
 
423
+ /**
424
+ * Get navigation config for auto-generated menus
425
+ * Used by ChainRegistry to build nav sections
426
+ *
427
+ * @returns {object} Nav config { icon, section, weight, visible }
428
+ */
429
+ get nav() {
430
+ return this._nav || {}
431
+ }
432
+
418
433
  // ============ PERMISSIONS ============
419
434
 
420
435
  /**
package/src/index.js CHANGED
@@ -38,6 +38,9 @@ export * from './module/index.js'
38
38
  // Zones
39
39
  export * from './zones/index.js'
40
40
 
41
+ // Chain (active navigation stack)
42
+ export * from './chain/index.js'
43
+
41
44
  // Hooks
42
45
  export * from './hooks/index.js'
43
46
 
@@ -63,6 +63,7 @@ import { defaultStorageResolver } from '../entity/storage/factory.js'
63
63
  import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
64
64
  import { createEventRouter } from './EventRouter.js'
65
65
  import { createSSEBridge } from './SSEBridge.js'
66
+ import { ActiveStack } from '../chain/ActiveStack.js'
66
67
 
67
68
  // Debug imports are dynamic to enable tree-shaking in production
68
69
  // When debugBar: false/undefined, no debug code is bundled
@@ -198,6 +199,7 @@ export class Kernel {
198
199
  this._createSignalBus()
199
200
  this._createHookRegistry()
200
201
  this._createZoneRegistry()
202
+ this._createActiveStack()
201
203
  this._createDeferredRegistry()
202
204
  // 2. Create orchestrator early (modules need it for ctx.entity())
203
205
  this._createOrchestrator()
@@ -248,6 +250,7 @@ export class Kernel {
248
250
  this._createSignalBus()
249
251
  this._createHookRegistry()
250
252
  this._createZoneRegistry()
253
+ this._createActiveStack()
251
254
  this._createDeferredRegistry()
252
255
  // 2. Create orchestrator early (modules need it for ctx.entity())
253
256
  this._createOrchestrator()
@@ -868,6 +871,14 @@ export class Kernel {
868
871
  this.zoneRegistry = createZoneRegistry({ debug })
869
872
  }
870
873
 
874
+ /**
875
+ * Create active stack for navigation state
876
+ * Holds the current stack of active items (entity, id, data, label).
877
+ */
878
+ _createActiveStack() {
879
+ this.activeStack = new ActiveStack()
880
+ }
881
+
871
882
  /**
872
883
  * Create deferred registry for async service loading
873
884
  * Enables loose coupling between services and components via named promises.
@@ -1040,6 +1051,9 @@ export class Kernel {
1040
1051
  // Zone registry injection
1041
1052
  app.provide('qdadmZoneRegistry', this.zoneRegistry)
1042
1053
 
1054
+ // Active stack injection (for navigation context)
1055
+ app.provide('qdadmActiveStack', this.activeStack)
1056
+
1043
1057
  // Dev mode: expose qdadm services on window for DevTools inspection
1044
1058
  if (this.options.debug && typeof window !== 'undefined') {
1045
1059
  window.__qdadm = {
@@ -1048,6 +1062,7 @@ export class Kernel {
1048
1062
  signals: this.signals,
1049
1063
  hooks: this.hookRegistry,
1050
1064
  zones: this.zoneRegistry,
1065
+ activeStack: this.activeStack,
1051
1066
  deferred: this.deferred,
1052
1067
  router: this.router,
1053
1068
  // Helper to get a manager quickly
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { managerFactory } from '../entity/factory.js'
28
- import { registry } from '../module/moduleRegistry.js'
28
+ import { registry, getRoutes } from '../module/moduleRegistry.js'
29
29
  import { UsersManager } from '../security/UsersManager.js'
30
30
 
31
31
  export class KernelContext {
@@ -320,6 +320,7 @@ export class KernelContext {
320
320
  * Generates routes following qdadm conventions:
321
321
  * - Entity 'books' → route prefix 'book'
322
322
  * - List: /books → name 'book'
323
+ * - Show: /books/:id → name 'book-show' (optional)
323
324
  * - Create: /books/create → name 'book-create'
324
325
  * - Edit: /books/:id/edit → name 'book-edit'
325
326
  *
@@ -328,6 +329,7 @@ export class KernelContext {
328
329
  * @param {string} entity - Entity name (plural, e.g., 'books', 'users')
329
330
  * @param {object} pages - Page components (lazy imports)
330
331
  * @param {Function} pages.list - List page: () => import('./pages/List.vue')
332
+ * @param {Function} [pages.show] - Show page (read-only detail view)
331
333
  * @param {Function} [pages.form] - Single form for create+edit (recommended)
332
334
  * @param {Function} [pages.create] - Separate create page (alternative to form)
333
335
  * @param {Function} [pages.edit] - Separate edit page (alternative to form)
@@ -336,7 +338,10 @@ export class KernelContext {
336
338
  * @param {string} options.nav.section - Nav section (e.g., 'Library')
337
339
  * @param {string} [options.nav.icon] - Icon class (e.g., 'pi pi-book')
338
340
  * @param {string} [options.nav.label] - Display label (default: capitalized entity)
339
- * @param {string} [options.routePrefix] - Override route prefix (default: singularized entity)
341
+ * @param {string} [options.routePrefix] - Override route prefix (default: singularized entity, or parentPrefix-singularized for children)
342
+ * @param {string} [options.parentRoute] - Parent route name for child entities (e.g., 'book' to mount under books/:bookId)
343
+ * @param {string} [options.foreignKey] - Foreign key field linking to parent (e.g., 'book_id')
344
+ * @param {string} [options.label] - Label for navlinks (defaults to entity labelPlural from manager)
340
345
  * @returns {this}
341
346
  *
342
347
  * @example
@@ -344,6 +349,13 @@ export class KernelContext {
344
349
  * ctx.crud('countries', { list: () => import('./pages/CountriesList.vue') })
345
350
  *
346
351
  * @example
352
+ * // List + show (read-only with detail view)
353
+ * ctx.crud('countries', {
354
+ * list: () => import('./pages/CountriesList.vue'),
355
+ * show: () => import('./pages/CountryShow.vue')
356
+ * })
357
+ *
358
+ * @example
347
359
  * // Single form pattern (recommended)
348
360
  * ctx.crud('books', {
349
361
  * list: () => import('./pages/BookList.vue'),
@@ -353,6 +365,17 @@ export class KernelContext {
353
365
  * })
354
366
  *
355
367
  * @example
368
+ * // Child entity mounted under parent route
369
+ * ctx.crud('loans', {
370
+ * list: () => import('./pages/BookLoans.vue'),
371
+ * form: () => import('./pages/BookLoanForm.vue')
372
+ * }, {
373
+ * parentRoute: 'book',
374
+ * foreignKey: 'book_id',
375
+ * routePrefix: 'book-loan' // Optional: defaults to 'book-loan'
376
+ * })
377
+ *
378
+ * @example
356
379
  * // Separate create/edit pages
357
380
  * ctx.crud('users', {
358
381
  * list: () => import('./pages/UserList.vue'),
@@ -363,18 +386,60 @@ export class KernelContext {
363
386
  * })
364
387
  */
365
388
  crud(entity, pages, options = {}) {
366
- // Derive route prefix: 'books' 'book', 'countries' → 'country'
367
- const routePrefix = options.routePrefix || this._singularize(entity)
389
+ // Entity name is always used for permission binding
390
+ // Manager may not be registered yet (child entity before parent module loads)
391
+ const entityBinding = entity
392
+ const manager = this._kernel.orchestrator?.isRegistered(entity)
393
+ ? this._kernel.orchestrator.get(entity)
394
+ : null
395
+ const idParam = manager?.idField || 'id'
396
+
397
+ // Handle parent route configuration
398
+ let basePath = entity
399
+ let parentConfig = null
400
+ let parentRoutePrefix = null
401
+
402
+ if (options.parentRoute) {
403
+ // Find parent route info from registered routes
404
+ const parentRouteName = options.parentRoute
405
+ const allRoutes = getRoutes()
406
+ const parentRoute = allRoutes.find(r => r.name === parentRouteName)
407
+
408
+ if (parentRoute) {
409
+ // Get parent entity from route meta
410
+ const parentEntityName = parentRoute.meta?.entity
411
+ const parentManager = parentEntityName
412
+ ? this._kernel.orchestrator?.get(parentEntityName)
413
+ : null
414
+ const parentIdParam = parentManager?.idField || 'id'
415
+
416
+ // Build base path: parentPath/:parentId/entity
417
+ // e.g., books/:bookId/loans
418
+ const parentBasePath = parentRoute.path.replace(/\/(create|:.*)?$/, '') || parentEntityName
419
+ basePath = `${parentBasePath}/:${parentIdParam}/${entity}`
420
+
421
+ // Build parent config for route meta
422
+ parentConfig = {
423
+ entity: parentEntityName,
424
+ param: parentIdParam,
425
+ foreignKey: options.foreignKey || `${this._singularize(parentEntityName)}_id`
426
+ }
427
+
428
+ // Store parent route prefix for derived naming
429
+ parentRoutePrefix = parentRouteName
430
+ }
431
+ }
368
432
 
369
- // Check if entity is actually registered in orchestrator (for permission binding)
370
- // If not registered, don't bind entity to routes/nav (allows pure route registration)
371
- const hasEntity = this._kernel.orchestrator?.isRegistered(entity) ?? false
372
- const entityBinding = hasEntity ? entity : undefined
433
+ // Derive route prefix
434
+ // - With parent: 'book' + '-loan' 'book-loan'
435
+ // - Without parent: 'books' → 'book'
436
+ const routePrefix = options.routePrefix
437
+ || (parentRoutePrefix ? `${parentRoutePrefix}-${this._singularize(entity)}` : this._singularize(entity))
373
438
 
374
439
  // Build routes array
375
440
  const routes = []
376
441
 
377
- // List route (always required)
442
+ // List route
378
443
  if (pages.list) {
379
444
  routes.push({
380
445
  path: '',
@@ -384,6 +449,16 @@ export class KernelContext {
384
449
  })
385
450
  }
386
451
 
452
+ // Show route (read-only detail view)
453
+ if (pages.show) {
454
+ routes.push({
455
+ path: `:${idParam}`,
456
+ name: `${routePrefix}-show`,
457
+ component: pages.show,
458
+ meta: { layout: 'show' }
459
+ })
460
+ }
461
+
387
462
  // Form routes - single form or separate create/edit
388
463
  if (pages.form) {
389
464
  // Single form pattern (recommended)
@@ -393,7 +468,7 @@ export class KernelContext {
393
468
  component: pages.form
394
469
  })
395
470
  routes.push({
396
- path: ':id/edit',
471
+ path: `:${idParam}/edit`,
397
472
  name: `${routePrefix}-edit`,
398
473
  component: pages.form
399
474
  })
@@ -408,21 +483,35 @@ export class KernelContext {
408
483
  }
409
484
  if (pages.edit) {
410
485
  routes.push({
411
- path: ':id/edit',
486
+ path: `:${idParam}/edit`,
412
487
  name: `${routePrefix}-edit`,
413
488
  component: pages.edit
414
489
  })
415
490
  }
416
491
  }
417
492
 
418
- // Register routes (with entity binding only if entity exists)
419
- const routeOpts = entityBinding ? { entity: entityBinding } : {}
420
- this.routes(entity, routes, routeOpts)
493
+ // Build route options
494
+ const routeOpts = {}
495
+ // Set entity if:
496
+ // 1. Entity is registered (manager exists), OR
497
+ // 2. This is a child route (parentConfig) - needs entity binding for permission checks
498
+ if (manager || parentConfig) {
499
+ routeOpts.entity = entityBinding
500
+ }
501
+ if (parentConfig) {
502
+ routeOpts.parent = parentConfig
503
+ }
504
+ if (options.label) {
505
+ routeOpts.label = options.label
506
+ }
507
+
508
+ // Register routes
509
+ this.routes(basePath, routes, routeOpts)
421
510
 
422
511
  // Register route family for active state detection
423
512
  this.routeFamily(routePrefix, [`${routePrefix}-`])
424
513
 
425
- // Register nav item if provided
514
+ // Register nav item if provided (typically not for child entities)
426
515
  if (options.nav) {
427
516
  const label = options.nav.label || this._capitalize(entity)
428
517
  const navItem = {
@@ -431,7 +520,9 @@ export class KernelContext {
431
520
  icon: options.nav.icon,
432
521
  label
433
522
  }
434
- if (entityBinding) {
523
+ // Only set entity on nav item if registered (to avoid permission check failure)
524
+ // Routes always get entity binding, but nav items need it to be resolvable
525
+ if (manager) {
435
526
  navItem.entity = entityBinding
436
527
  }
437
528
  this.navItem(navItem)
@@ -50,20 +50,13 @@ const registry = {
50
50
  /**
51
51
  * Add routes for this module
52
52
  *
53
- * CONVENTION: Entity item routes MUST use :id as param name
54
- * - List route: 'books' → /books
55
- * - Item route: 'books/:id' → /books/:id (MUST be :id, not :bookId or :uuid)
56
- * - Child route: 'books/:id/reviews' → parent.param = 'id'
57
- *
58
- * This convention is required for PageNav, breadcrumbs, and navigation to work correctly.
59
- *
60
53
  * @param {string} prefix - Path prefix for all routes (e.g., 'books' or 'books/:id/reviews')
61
54
  * @param {Array} moduleRoutes - Route definitions with relative paths
62
55
  * @param {object} options - Route options
63
56
  * @param {string} [options.entity] - Entity name for permission checking
64
57
  * @param {object} [options.parent] - Parent entity config for child routes
65
58
  * @param {string} options.parent.entity - Parent entity name (e.g., 'books')
66
- * @param {string} options.parent.param - Route param for parent ID (MUST be 'id')
59
+ * @param {string} options.parent.param - Route param for parent ID
67
60
  * @param {string} options.parent.foreignKey - Foreign key field (e.g., 'book_id')
68
61
  * @param {string} [options.parent.itemRoute] - Override parent item route (auto: parentEntity.routePrefix + '-edit')
69
62
  * @param {string} [options.label] - Label for navlinks (defaults to entity labelPlural)