qdadm 0.51.7 → 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 +1 -1
- package/src/chain/ActiveStack.js +75 -0
- package/src/chain/index.js +12 -0
- package/src/chain/useActiveStack.js +135 -0
- package/src/components/layout/AppLayout.vue +3 -22
- package/src/components/layout/PageNav.vue +30 -142
- package/src/composables/index.js +0 -1
- package/src/composables/useCurrentEntity.js +22 -27
- package/src/composables/useEntityItemFormPage.js +19 -9
- package/src/composables/useEntityItemPage.js +16 -24
- package/src/composables/useForm.js +6 -6
- package/src/composables/useListPage.js +2 -2
- package/src/composables/useNavContext.js +21 -44
- package/src/entity/EntityManager.js +16 -1
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +15 -0
- package/src/kernel/KernelContext.js +107 -16
- package/src/module/moduleRegistry.js +1 -8
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
190
|
-
const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext(
|
|
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 -
|
|
3
|
+
* PageNav - Navigation provider for navlinks (child/sibling routes)
|
|
4
4
|
*
|
|
5
|
-
* This component
|
|
6
|
-
*
|
|
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 (
|
|
10
|
+
* ↑ breadcrumb (from useNavContext) ↑ navlinks (from PageNav)
|
|
11
11
|
*
|
|
12
12
|
* Auto-detects from current route:
|
|
13
|
-
* -
|
|
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
|
-
* -
|
|
18
|
-
* - parentEntity: Parent entity data (for parent label in breadcrumb)
|
|
16
|
+
* - showDetailsLink: Show "Details" link in navlinks (default: false)
|
|
19
17
|
*/
|
|
20
|
-
import { computed,
|
|
21
|
-
import { useRoute
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
169
|
+
<!-- PageNav provides navlinks to AppLayout via inject, renders nothing -->
|
|
282
170
|
</template>
|
package/src/composables/index.js
CHANGED
|
@@ -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
|
-
*
|
|
2
|
+
* @deprecated Use useActiveStack() instead
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Legacy composable for setting breadcrumb entity data.
|
|
5
|
+
* This has been replaced by the activeStack system.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Migration:
|
|
8
8
|
* ```js
|
|
9
|
+
* // Before (deprecated)
|
|
9
10
|
* const { setBreadcrumbEntity } = useCurrentEntity()
|
|
11
|
+
* setBreadcrumbEntity(data)
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
18
|
+
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
27
19
|
|
|
28
20
|
/**
|
|
29
|
-
*
|
|
30
|
-
* @returns {{ setBreadcrumbEntity: (data: object, level?: number) => void }}
|
|
21
|
+
* @deprecated Use useActiveStack() instead
|
|
31
22
|
*/
|
|
32
23
|
export function useCurrentEntity() {
|
|
33
|
-
|
|
24
|
+
console.warn('[qdadm] useCurrentEntity is deprecated. Use useActiveStack() instead.')
|
|
25
|
+
|
|
26
|
+
const stack = useActiveStack()
|
|
34
27
|
|
|
35
28
|
/**
|
|
36
|
-
*
|
|
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 (
|
|
42
|
-
|
|
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
|
|
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'),
|
|
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,
|
|
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
|
-
//
|
|
255
|
-
|
|
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?.
|
|
330
|
+
const createdId = responseData?.[manager.idField]
|
|
332
331
|
if (createdId) {
|
|
333
|
-
// Build edit route: replace 'create' suffix with '
|
|
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,
|
|
337
|
+
params: { ...route.params, [manager.idField]: createdId }
|
|
339
338
|
})
|
|
340
339
|
}
|
|
341
340
|
}
|
|
@@ -1132,6 +1131,14 @@ export function useEntityItemFormPage(config = {}) {
|
|
|
1132
1131
|
}
|
|
1133
1132
|
})
|
|
1134
1133
|
|
|
1134
|
+
// Watch for route changes (e.g., create → edit navigation)
|
|
1135
|
+
// Vue reuses the component, so onMounted won't fire again
|
|
1136
|
+
watch(entityId, (newId, oldId) => {
|
|
1137
|
+
if (newId !== oldId && loadOnMount) {
|
|
1138
|
+
load()
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1135
1142
|
// ============ FORMPAGE PROPS/EVENTS ============
|
|
1136
1143
|
|
|
1137
1144
|
/**
|
|
@@ -1282,7 +1289,10 @@ export function useEntityItemFormPage(config = {}) {
|
|
|
1282
1289
|
|
|
1283
1290
|
// FormPage integration
|
|
1284
1291
|
props: formProps,
|
|
1285
|
-
events: formEvents
|
|
1292
|
+
events: formEvents,
|
|
1293
|
+
|
|
1294
|
+
// Active navigation stack (unified context)
|
|
1295
|
+
stack
|
|
1286
1296
|
}
|
|
1287
1297
|
|
|
1288
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
|
-
* -
|
|
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 {
|
|
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
|
-
//
|
|
95
|
-
const
|
|
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
|
-
//
|
|
190
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
271
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
98
|
-
const
|
|
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
|
-
//
|
|
249
|
-
|
|
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.
|
|
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: {
|
|
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: {
|
|
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
|
-
*
|
|
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
|
-
//
|
|
36
|
-
|
|
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
|
|
125
|
+
// ENTITY DATA FROM ACTIVESTACK
|
|
128
126
|
// ============================================================================
|
|
129
127
|
|
|
130
128
|
const chainData = ref(new Map()) // Map: chainIndex -> entityData
|
|
131
129
|
|
|
132
130
|
/**
|
|
133
|
-
*
|
|
131
|
+
* Build chainData from activeStack levels
|
|
134
132
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
150
|
-
let
|
|
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
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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
package/src/kernel/Kernel.js
CHANGED
|
@@ -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
|
-
//
|
|
367
|
-
|
|
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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
const
|
|
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
|
|
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:
|
|
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:
|
|
486
|
+
path: `:${idParam}/edit`,
|
|
412
487
|
name: `${routePrefix}-edit`,
|
|
413
488
|
component: pages.edit
|
|
414
489
|
})
|
|
415
490
|
}
|
|
416
491
|
}
|
|
417
492
|
|
|
418
|
-
//
|
|
419
|
-
const routeOpts =
|
|
420
|
-
|
|
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 (
|
|
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
|
|
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)
|