qdadm 0.15.0 → 0.16.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/components/BoolCell.vue +11 -6
- package/src/components/layout/AppLayout.vue +18 -9
- package/src/components/layout/PageHeader.vue +6 -9
- package/src/components/layout/PageNav.vue +15 -0
- package/src/composables/index.js +2 -0
- package/src/composables/useBreadcrumb.js +9 -5
- package/src/composables/useNavContext.js +372 -0
- package/src/composables/usePageTitle.js +59 -0
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +9 -3
- package/src/plugin.js +5 -0
package/package.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* BoolCell - Standardized boolean display for list columns
|
|
4
4
|
*
|
|
5
5
|
* Tristate display:
|
|
6
|
-
* - true: green check
|
|
7
|
-
* - false: red cross
|
|
6
|
+
* - true / "true" / 1: green check
|
|
7
|
+
* - false / "false" / 0: red cross
|
|
8
8
|
* - null/undefined: empty
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
@@ -14,15 +14,20 @@
|
|
|
14
14
|
* </template>
|
|
15
15
|
* </Column>
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
import { computed } from 'vue'
|
|
18
|
+
|
|
19
|
+
const props = defineProps({
|
|
18
20
|
value: {
|
|
19
|
-
type: Boolean,
|
|
21
|
+
type: [Boolean, String, Number],
|
|
20
22
|
default: null
|
|
21
23
|
}
|
|
22
24
|
})
|
|
25
|
+
|
|
26
|
+
const isTrue = computed(() => props.value === true || props.value === 'true' || props.value === 1)
|
|
27
|
+
const isFalse = computed(() => props.value === false || props.value === 'false' || props.value === 0)
|
|
23
28
|
</script>
|
|
24
29
|
|
|
25
30
|
<template>
|
|
26
|
-
<i v-if="
|
|
27
|
-
<i v-else-if="
|
|
31
|
+
<i v-if="isTrue" class="pi pi-check" style="color: var(--p-green-500)" />
|
|
32
|
+
<i v-else-if="isFalse" class="pi pi-times" style="color: var(--p-red-500)" />
|
|
28
33
|
</template>
|
|
@@ -18,7 +18,7 @@ import { useNavigation } from '../../composables/useNavigation'
|
|
|
18
18
|
import { useApp } from '../../composables/useApp'
|
|
19
19
|
import { useAuth } from '../../composables/useAuth'
|
|
20
20
|
import { useGuardDialog } from '../../composables/useGuardStore'
|
|
21
|
-
import {
|
|
21
|
+
import { useNavContext } from '../../composables/useNavContext'
|
|
22
22
|
import Button from 'primevue/button'
|
|
23
23
|
import Breadcrumb from 'primevue/breadcrumb'
|
|
24
24
|
import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
|
|
@@ -125,18 +125,18 @@ function handleLogout() {
|
|
|
125
125
|
const slots = useSlots()
|
|
126
126
|
const hasSlotContent = computed(() => !!slots.default)
|
|
127
127
|
|
|
128
|
-
//
|
|
129
|
-
const {
|
|
128
|
+
// Navigation context (breadcrumb + navlinks from route config)
|
|
129
|
+
const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext()
|
|
130
130
|
|
|
131
|
-
// Allow child pages to override breadcrumb via provide/inject
|
|
131
|
+
// Allow child pages to override breadcrumb/navlinks via provide/inject
|
|
132
132
|
const breadcrumbOverride = ref(null)
|
|
133
133
|
const navlinksOverride = ref(null)
|
|
134
134
|
provide('qdadmBreadcrumbOverride', breadcrumbOverride)
|
|
135
135
|
provide('qdadmNavlinksOverride', navlinksOverride)
|
|
136
136
|
|
|
137
|
-
// Use override if provided, otherwise default
|
|
137
|
+
// Use override if provided, otherwise default from useNavContext
|
|
138
138
|
const breadcrumbItems = computed(() => breadcrumbOverride.value || defaultBreadcrumb.value)
|
|
139
|
-
const navlinks = computed(() => navlinksOverride.value ||
|
|
139
|
+
const navlinks = computed(() => navlinksOverride.value || defaultNavlinks.value)
|
|
140
140
|
|
|
141
141
|
// Show breadcrumb if enabled, has items, and not on home page
|
|
142
142
|
const showBreadcrumb = computed(() => {
|
|
@@ -152,8 +152,10 @@ const showBreadcrumb = computed(() => {
|
|
|
152
152
|
<!-- Sidebar -->
|
|
153
153
|
<aside class="sidebar">
|
|
154
154
|
<div class="sidebar-header">
|
|
155
|
-
<
|
|
156
|
-
|
|
155
|
+
<div class="sidebar-header-top">
|
|
156
|
+
<img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
|
|
157
|
+
<h1 v-else>{{ app.name }}</h1>
|
|
158
|
+
</div>
|
|
157
159
|
<span v-if="app.version" class="version">v{{ app.version }}</span>
|
|
158
160
|
</div>
|
|
159
161
|
|
|
@@ -288,6 +290,13 @@ const showBreadcrumb = computed(() => {
|
|
|
288
290
|
.sidebar-header {
|
|
289
291
|
padding: 1.5rem;
|
|
290
292
|
border-bottom: 1px solid var(--p-surface-700, #334155);
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
align-items: flex-start;
|
|
296
|
+
gap: 0.5rem;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.sidebar-header-top {
|
|
291
300
|
display: flex;
|
|
292
301
|
align-items: center;
|
|
293
302
|
gap: 0.5rem;
|
|
@@ -305,7 +314,7 @@ const showBreadcrumb = computed(() => {
|
|
|
305
314
|
}
|
|
306
315
|
|
|
307
316
|
.version {
|
|
308
|
-
font-size: 0.
|
|
317
|
+
font-size: 0.625rem;
|
|
309
318
|
color: var(--p-surface-400, #94a3b8);
|
|
310
319
|
background: var(--p-surface-700, #334155);
|
|
311
320
|
padding: 0.125rem 0.375rem;
|
|
@@ -42,17 +42,13 @@ const effectiveTitleParts = computed(() => {
|
|
|
42
42
|
return props.titleParts || injectedTitleParts?.value || null
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
// Simple title from usePageTitle('My Title')
|
|
46
|
+
const simpleTitle = computed(() => effectiveTitleParts.value?.simple)
|
|
47
|
+
|
|
45
48
|
// Compute title display
|
|
46
49
|
const hasDecoratedTitle = computed(() => {
|
|
47
50
|
return effectiveTitleParts.value?.entityLabel
|
|
48
51
|
})
|
|
49
|
-
|
|
50
|
-
const titleBase = computed(() => {
|
|
51
|
-
if (effectiveTitleParts.value) {
|
|
52
|
-
return `${effectiveTitleParts.value.action} ${effectiveTitleParts.value.entityName}`
|
|
53
|
-
}
|
|
54
|
-
return props.title
|
|
55
|
-
})
|
|
56
52
|
</script>
|
|
57
53
|
|
|
58
54
|
<template>
|
|
@@ -62,8 +58,9 @@ const titleBase = computed(() => {
|
|
|
62
58
|
<div class="page-header-left">
|
|
63
59
|
<div>
|
|
64
60
|
<h1 class="page-title">
|
|
65
|
-
<template v-if="
|
|
66
|
-
<
|
|
61
|
+
<template v-if="simpleTitle">{{ simpleTitle }}</template>
|
|
62
|
+
<template v-else-if="hasDecoratedTitle"><span class="entity-label">{{ effectiveTitleParts.entityLabel }}</span></template>
|
|
63
|
+
<span v-if="effectiveTitleParts && !simpleTitle" class="action-badge">{{ effectiveTitleParts.action }} {{ effectiveTitleParts.entityName }}</span>
|
|
67
64
|
<template v-if="!effectiveTitleParts">{{ title }}</template>
|
|
68
65
|
</h1>
|
|
69
66
|
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
|
@@ -25,6 +25,7 @@ import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
|
|
|
25
25
|
// Inject override refs from AppLayout
|
|
26
26
|
const breadcrumbOverride = inject('qdadmBreadcrumbOverride', null)
|
|
27
27
|
const navlinksOverride = inject('qdadmNavlinksOverride', null)
|
|
28
|
+
const homeRouteName = inject('qdadmHomeRoute', null)
|
|
28
29
|
|
|
29
30
|
const props = defineProps({
|
|
30
31
|
entity: { type: Object, default: null },
|
|
@@ -60,10 +61,24 @@ watch(() => [parentConfig.value, route.params], async () => {
|
|
|
60
61
|
}
|
|
61
62
|
}, { immediate: true })
|
|
62
63
|
|
|
64
|
+
// Home breadcrumb item
|
|
65
|
+
const homeItem = computed(() => {
|
|
66
|
+
if (!homeRouteName) return null
|
|
67
|
+
const routes = router.getRoutes()
|
|
68
|
+
if (!routes.some(r => r.name === homeRouteName)) return null
|
|
69
|
+
const label = homeRouteName === 'dashboard' ? 'Dashboard' : 'Home'
|
|
70
|
+
return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
|
|
71
|
+
})
|
|
72
|
+
|
|
63
73
|
// Build breadcrumb items
|
|
64
74
|
const breadcrumbItems = computed(() => {
|
|
65
75
|
const items = []
|
|
66
76
|
|
|
77
|
+
// Always start with Home if configured
|
|
78
|
+
if (homeItem.value) {
|
|
79
|
+
items.push(homeItem.value)
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
if (!parentConfig.value) {
|
|
68
83
|
// No parent - use simple breadcrumb from entity
|
|
69
84
|
const entityName = route.meta?.entity
|
package/src/composables/index.js
CHANGED
|
@@ -10,10 +10,12 @@ export { useForm } from './useForm'
|
|
|
10
10
|
export * from './useJsonSyntax'
|
|
11
11
|
export { useListPageBuilder, PAGE_SIZE_OPTIONS } from './useListPageBuilder'
|
|
12
12
|
export { usePageBuilder } from './usePageBuilder'
|
|
13
|
+
export { usePageTitle } from './usePageTitle'
|
|
13
14
|
export { useSubEditor } from './useSubEditor'
|
|
14
15
|
export { useTabSync } from './useTabSync'
|
|
15
16
|
export { useApp } from './useApp'
|
|
16
17
|
export { useAuth } from './useAuth'
|
|
18
|
+
export { useNavContext } from './useNavContext'
|
|
17
19
|
export { useNavigation } from './useNavigation'
|
|
18
20
|
export { useStatus } from './useStatus'
|
|
19
21
|
export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed } from 'vue'
|
|
1
|
+
import { computed, inject } from 'vue'
|
|
2
2
|
import { useRoute, useRouter } from 'vue-router'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -35,9 +35,11 @@ import { useRoute, useRouter } from 'vue-router'
|
|
|
35
35
|
export function useBreadcrumb(options = {}) {
|
|
36
36
|
const route = useRoute()
|
|
37
37
|
const router = useRouter()
|
|
38
|
+
const homeRouteName = inject('qdadmHomeRoute', null)
|
|
38
39
|
|
|
39
40
|
// Label mapping for common route names
|
|
40
41
|
const labelMap = {
|
|
42
|
+
home: 'Home',
|
|
41
43
|
dashboard: 'Dashboard',
|
|
42
44
|
users: 'Users',
|
|
43
45
|
roles: 'Roles',
|
|
@@ -102,13 +104,15 @@ export function useBreadcrumb(options = {}) {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
|
-
* Get home breadcrumb item
|
|
107
|
+
* Get home breadcrumb item from configured homeRoute
|
|
106
108
|
*/
|
|
107
109
|
function getHomeItem() {
|
|
108
|
-
if (routeExists(
|
|
109
|
-
return
|
|
110
|
+
if (!homeRouteName || !routeExists(homeRouteName)) {
|
|
111
|
+
return null
|
|
110
112
|
}
|
|
111
|
-
|
|
113
|
+
// Use label from labelMap or capitalize route name
|
|
114
|
+
const label = labelMap[homeRouteName] || capitalize(homeRouteName)
|
|
115
|
+
return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNavContext - Route-aware navigation context for breadcrumb and navlinks
|
|
3
|
+
*
|
|
4
|
+
* Builds navigation from route path pattern analysis (not heuristics).
|
|
5
|
+
*
|
|
6
|
+
* The route path pattern defines the navigation structure:
|
|
7
|
+
* - Static segments (e.g., 'books') → entity list
|
|
8
|
+
* - Param segments (e.g., ':id', ':bookId') → entity item
|
|
9
|
+
* - Action segments (e.g., 'edit', 'create') → ignored
|
|
10
|
+
*
|
|
11
|
+
* Route meta configuration:
|
|
12
|
+
* - meta.entity: Entity managed by this route (required)
|
|
13
|
+
* - meta.parent: Parent entity config for nested routes
|
|
14
|
+
* - parent.entity: Parent entity name
|
|
15
|
+
* - parent.param: Route param for parent ID
|
|
16
|
+
*
|
|
17
|
+
* Examples:
|
|
18
|
+
* Path: /books meta: { entity: 'books' }
|
|
19
|
+
* → Home > Books
|
|
20
|
+
*
|
|
21
|
+
* Path: /books/:id/edit meta: { entity: 'books' }
|
|
22
|
+
* → Home > Books > "Le Petit Prince"
|
|
23
|
+
*
|
|
24
|
+
* Path: /books/:bookId/loans meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
|
|
25
|
+
* → Home > Books > "Le Petit Prince" > Loans
|
|
26
|
+
*
|
|
27
|
+
* Path: /books/:bookId/loans/:id/edit meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
|
|
28
|
+
* → Home > Books > "Le Petit Prince" > Loans > "Loan #abc123"
|
|
29
|
+
*/
|
|
30
|
+
import { ref, computed, watch, inject, unref } from 'vue'
|
|
31
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
32
|
+
import { getSiblingRoutes } from '../module/moduleRegistry.js'
|
|
33
|
+
|
|
34
|
+
// Action segments that don't appear in breadcrumb
|
|
35
|
+
const ACTION_SEGMENTS = ['edit', 'create', 'new', 'show', 'view', 'delete']
|
|
36
|
+
|
|
37
|
+
export function useNavContext(options = {}) {
|
|
38
|
+
const route = useRoute()
|
|
39
|
+
const router = useRouter()
|
|
40
|
+
|
|
41
|
+
// Injected dependencies
|
|
42
|
+
const orchestrator = inject('qdadmOrchestrator', null)
|
|
43
|
+
const homeRouteName = inject('qdadmHomeRoute', null)
|
|
44
|
+
|
|
45
|
+
// Entity data cache
|
|
46
|
+
const entityDataCache = ref(new Map())
|
|
47
|
+
|
|
48
|
+
function getManager(entityName) {
|
|
49
|
+
return orchestrator?.get(entityName)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function routeExists(name) {
|
|
53
|
+
return router.getRoutes().some(r => r.name === name)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// PATH PATTERN ANALYSIS
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse route path pattern into typed segments
|
|
62
|
+
*
|
|
63
|
+
* Input: '/books/:bookId/loans/:id/edit'
|
|
64
|
+
* Output: [
|
|
65
|
+
* { type: 'static', value: 'books' },
|
|
66
|
+
* { type: 'param', value: 'bookId' },
|
|
67
|
+
* { type: 'static', value: 'loans' },
|
|
68
|
+
* { type: 'param', value: 'id' },
|
|
69
|
+
* { type: 'action', value: 'edit' }
|
|
70
|
+
* ]
|
|
71
|
+
*/
|
|
72
|
+
function parsePathPattern(pathPattern) {
|
|
73
|
+
const segments = []
|
|
74
|
+
const parts = pathPattern.split('/').filter(Boolean)
|
|
75
|
+
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (part.startsWith(':')) {
|
|
78
|
+
// Param segment: :id, :bookId
|
|
79
|
+
segments.push({ type: 'param', value: part.slice(1) })
|
|
80
|
+
} else if (ACTION_SEGMENTS.includes(part.toLowerCase())) {
|
|
81
|
+
// Action segment: edit, create, show
|
|
82
|
+
segments.push({ type: 'action', value: part })
|
|
83
|
+
} else {
|
|
84
|
+
// Static segment: books, loans
|
|
85
|
+
segments.push({ type: 'static', value: part })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return segments
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build navigation chain from parsed path segments + route meta
|
|
94
|
+
*
|
|
95
|
+
* Uses route meta to know which entity each static segment represents.
|
|
96
|
+
* The meta.parent chain declares the entity hierarchy.
|
|
97
|
+
*/
|
|
98
|
+
function buildNavChain(pathSegments, routeMeta, routeParams) {
|
|
99
|
+
const chain = []
|
|
100
|
+
const meta = routeMeta || {}
|
|
101
|
+
|
|
102
|
+
// Collect all entities in the hierarchy (parent chain + current)
|
|
103
|
+
const entityHierarchy = []
|
|
104
|
+
|
|
105
|
+
// Build parent chain (oldest ancestor first)
|
|
106
|
+
function collectParents(parentConfig) {
|
|
107
|
+
if (!parentConfig) return
|
|
108
|
+
const parentManager = getManager(parentConfig.entity)
|
|
109
|
+
if (!parentManager) return
|
|
110
|
+
|
|
111
|
+
// Check if this parent has its own parent
|
|
112
|
+
const parentRoute = router.getRoutes().find(r =>
|
|
113
|
+
r.name === `${parentManager.routePrefix}-edit`
|
|
114
|
+
)
|
|
115
|
+
if (parentRoute?.meta?.parent) {
|
|
116
|
+
collectParents(parentRoute.meta.parent)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
entityHierarchy.push({
|
|
120
|
+
entity: parentConfig.entity,
|
|
121
|
+
manager: parentManager,
|
|
122
|
+
idParam: parentConfig.param
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
collectParents(meta.parent)
|
|
127
|
+
|
|
128
|
+
// Add current entity
|
|
129
|
+
if (meta.entity) {
|
|
130
|
+
const currentManager = getManager(meta.entity)
|
|
131
|
+
if (currentManager) {
|
|
132
|
+
entityHierarchy.push({
|
|
133
|
+
entity: meta.entity,
|
|
134
|
+
manager: currentManager,
|
|
135
|
+
idParam: 'id' // Standard param for current entity
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Now build chain from hierarchy
|
|
141
|
+
for (const { entity, manager, idParam } of entityHierarchy) {
|
|
142
|
+
const entityId = routeParams[idParam]
|
|
143
|
+
|
|
144
|
+
// Add list segment
|
|
145
|
+
chain.push({
|
|
146
|
+
type: 'list',
|
|
147
|
+
entity,
|
|
148
|
+
manager,
|
|
149
|
+
label: manager.labelPlural || manager.name,
|
|
150
|
+
routeName: manager.routePrefix
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Add item segment if we have an ID for this entity
|
|
154
|
+
if (entityId) {
|
|
155
|
+
chain.push({
|
|
156
|
+
type: 'item',
|
|
157
|
+
entity,
|
|
158
|
+
manager,
|
|
159
|
+
id: entityId,
|
|
160
|
+
routeName: `${manager.routePrefix}-edit`
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle child-list case: when on a child route without :id
|
|
166
|
+
// The last segment is a child-list, not a regular list
|
|
167
|
+
if (meta.parent && !routeParams.id && chain.length > 0) {
|
|
168
|
+
const lastSegment = chain[chain.length - 1]
|
|
169
|
+
if (lastSegment.type === 'list') {
|
|
170
|
+
lastSegment.type = 'child-list'
|
|
171
|
+
lastSegment.navLabel = meta.navLabel
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return chain
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// COMPUTED NAVIGATION CHAIN
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parsed path segments from current route
|
|
184
|
+
*/
|
|
185
|
+
const pathSegments = computed(() => {
|
|
186
|
+
// Get the matched route's path pattern
|
|
187
|
+
const matched = route.matched
|
|
188
|
+
if (!matched.length) return []
|
|
189
|
+
|
|
190
|
+
// Use the last matched route's full path
|
|
191
|
+
const lastMatch = matched[matched.length - 1]
|
|
192
|
+
return parsePathPattern(lastMatch.path)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Navigation chain built from path analysis
|
|
197
|
+
*/
|
|
198
|
+
const navChain = computed(() => {
|
|
199
|
+
return buildNavChain(pathSegments.value, route.meta, route.params)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// ENTITY DATA FETCHING
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
const chainData = ref(new Map()) // Map: chainIndex -> entityData
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fetch entity data for all 'item' segments in the chain
|
|
210
|
+
*/
|
|
211
|
+
watch([navChain, () => options.entityData], async ([chain]) => {
|
|
212
|
+
chainData.value.clear()
|
|
213
|
+
const externalData = unref(options.entityData)
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < chain.length; i++) {
|
|
216
|
+
const segment = chain[i]
|
|
217
|
+
if (segment.type !== 'item') continue
|
|
218
|
+
|
|
219
|
+
// For the last item, use external data if provided (from useForm)
|
|
220
|
+
const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
|
|
221
|
+
if (isLastItem && externalData) {
|
|
222
|
+
chainData.value.set(i, externalData)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Fetch from manager
|
|
227
|
+
try {
|
|
228
|
+
const data = await segment.manager.get(segment.id)
|
|
229
|
+
chainData.value.set(i, data)
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}, { immediate: true, deep: true })
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// BREADCRUMB
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
const homeItem = computed(() => {
|
|
241
|
+
if (!homeRouteName || !routeExists(homeRouteName)) return null
|
|
242
|
+
return {
|
|
243
|
+
label: homeRouteName === 'dashboard' ? 'Dashboard' : 'Home',
|
|
244
|
+
to: { name: homeRouteName },
|
|
245
|
+
icon: 'pi pi-home'
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const breadcrumb = computed(() => {
|
|
250
|
+
const items = []
|
|
251
|
+
const chain = navChain.value
|
|
252
|
+
|
|
253
|
+
// Home
|
|
254
|
+
if (homeItem.value) {
|
|
255
|
+
items.push(homeItem.value)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Build from chain
|
|
259
|
+
for (let i = 0; i < chain.length; i++) {
|
|
260
|
+
const segment = chain[i]
|
|
261
|
+
const isLast = i === chain.length - 1
|
|
262
|
+
|
|
263
|
+
if (segment.type === 'list') {
|
|
264
|
+
items.push({
|
|
265
|
+
label: segment.label,
|
|
266
|
+
to: { name: segment.routeName }
|
|
267
|
+
})
|
|
268
|
+
} else if (segment.type === 'item') {
|
|
269
|
+
const data = chainData.value.get(i)
|
|
270
|
+
const label = data ? segment.manager.getEntityLabel(data) : '...'
|
|
271
|
+
|
|
272
|
+
items.push({
|
|
273
|
+
label,
|
|
274
|
+
to: isLast ? null : { name: segment.routeName, params: { id: segment.id } }
|
|
275
|
+
})
|
|
276
|
+
} else if (segment.type === 'child-list') {
|
|
277
|
+
items.push({
|
|
278
|
+
label: segment.navLabel || segment.label
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return items
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// ============================================================================
|
|
287
|
+
// NAVLINKS (for child routes)
|
|
288
|
+
// ============================================================================
|
|
289
|
+
|
|
290
|
+
const parentConfig = computed(() => route.meta?.parent)
|
|
291
|
+
|
|
292
|
+
const parentId = computed(() => {
|
|
293
|
+
if (!parentConfig.value) return null
|
|
294
|
+
return route.params[parentConfig.value.param]
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const navlinks = computed(() => {
|
|
298
|
+
if (!parentConfig.value) return []
|
|
299
|
+
|
|
300
|
+
const { entity: parentEntity, param, itemRoute } = parentConfig.value
|
|
301
|
+
const parentManager = getManager(parentEntity)
|
|
302
|
+
if (!parentManager) return []
|
|
303
|
+
|
|
304
|
+
const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
|
|
305
|
+
const isOnParent = route.name === parentRouteName
|
|
306
|
+
|
|
307
|
+
// Details link
|
|
308
|
+
const links = [{
|
|
309
|
+
label: 'Details',
|
|
310
|
+
to: { name: parentRouteName, params: { id: parentId.value } },
|
|
311
|
+
active: isOnParent
|
|
312
|
+
}]
|
|
313
|
+
|
|
314
|
+
// Sibling routes
|
|
315
|
+
const siblings = getSiblingRoutes(parentEntity, param)
|
|
316
|
+
for (const sibling of siblings) {
|
|
317
|
+
const sibManager = sibling.meta?.entity ? getManager(sibling.meta.entity) : null
|
|
318
|
+
links.push({
|
|
319
|
+
label: sibling.meta?.navLabel || sibManager?.labelPlural || sibling.name,
|
|
320
|
+
to: { name: sibling.name, params: route.params },
|
|
321
|
+
active: route.name === sibling.name
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return links
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// CONVENIENCE ACCESSORS
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
const entityData = computed(() => {
|
|
333
|
+
const chain = navChain.value
|
|
334
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
335
|
+
if (chain[i].type === 'item') {
|
|
336
|
+
return chainData.value.get(i) || null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const parentData = computed(() => {
|
|
343
|
+
const chain = navChain.value
|
|
344
|
+
let foundCurrent = false
|
|
345
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
346
|
+
if (chain[i].type === 'item') {
|
|
347
|
+
if (foundCurrent) return chainData.value.get(i) || null
|
|
348
|
+
foundCurrent = true
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
// Analysis
|
|
356
|
+
pathSegments,
|
|
357
|
+
navChain,
|
|
358
|
+
|
|
359
|
+
// Data
|
|
360
|
+
entityData,
|
|
361
|
+
parentData,
|
|
362
|
+
chainData,
|
|
363
|
+
|
|
364
|
+
// Navigation
|
|
365
|
+
breadcrumb,
|
|
366
|
+
navlinks,
|
|
367
|
+
|
|
368
|
+
// Helpers
|
|
369
|
+
parentConfig,
|
|
370
|
+
parentId
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePageTitle - Provide custom page title for PageHeader
|
|
3
|
+
*
|
|
4
|
+
* Use this composable in custom pages to set the title displayed in PageHeader.
|
|
5
|
+
* For standard CRUD pages, useForm handles this automatically.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* // Simple title
|
|
10
|
+
* const { setTitle } = usePageTitle('My Custom Page')
|
|
11
|
+
*
|
|
12
|
+
* // Decorated title (entityLabel shown prominently, action+entityName as badge)
|
|
13
|
+
* usePageTitle({
|
|
14
|
+
* action: 'View',
|
|
15
|
+
* entityName: 'Stats',
|
|
16
|
+
* entityLabel: 'Dashboard'
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* // Reactive updates
|
|
20
|
+
* const { setTitle } = usePageTitle('Initial')
|
|
21
|
+
* setTitle('Updated Title')
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { ref, provide, watchEffect, isRef, unref } from 'vue'
|
|
25
|
+
|
|
26
|
+
export function usePageTitle(initialTitle = null) {
|
|
27
|
+
const titleParts = ref(null)
|
|
28
|
+
|
|
29
|
+
// Convert string to titleParts format (simple title)
|
|
30
|
+
function normalize(title) {
|
|
31
|
+
if (!title) return null
|
|
32
|
+
if (typeof title === 'string') {
|
|
33
|
+
return { simple: title }
|
|
34
|
+
}
|
|
35
|
+
return title
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Set title (string or { action, entityName, entityLabel })
|
|
39
|
+
function setTitle(newTitle) {
|
|
40
|
+
titleParts.value = normalize(unref(newTitle))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Initialize with provided value
|
|
44
|
+
if (initialTitle !== null) {
|
|
45
|
+
if (isRef(initialTitle)) {
|
|
46
|
+
// Reactive: watch for changes
|
|
47
|
+
watchEffect(() => {
|
|
48
|
+
setTitle(initialTitle.value)
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
setTitle(initialTitle)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Provide to PageHeader via same key as useForm
|
|
56
|
+
provide('qdadmPageTitleParts', titleParts)
|
|
57
|
+
|
|
58
|
+
return { titleParts, setTitle }
|
|
59
|
+
}
|
package/src/index.js
CHANGED
package/src/kernel/Kernel.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { createApp } from 'vue'
|
|
41
41
|
import { createPinia } from 'pinia'
|
|
42
|
-
import { createRouter, createWebHistory } from 'vue-router'
|
|
42
|
+
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
|
43
43
|
import ToastService from 'primevue/toastservice'
|
|
44
44
|
import ConfirmationService from 'primevue/confirmationservice'
|
|
45
45
|
import Tooltip from 'primevue/tooltip'
|
|
@@ -61,6 +61,7 @@ export class Kernel {
|
|
|
61
61
|
* @param {string} options.homeRoute - Route name for home redirect (or object { name, component })
|
|
62
62
|
* @param {Array} options.coreRoutes - Additional routes as layout children (before module routes)
|
|
63
63
|
* @param {string} options.basePath - Base path for router (e.g., '/dashboard/')
|
|
64
|
+
* @param {boolean} options.hashMode - Use hash-based routing (/#/path) for static hosting
|
|
64
65
|
* @param {object} options.app - App config { name, shortName, version, logo, theme }
|
|
65
66
|
* @param {object} options.features - Feature toggles { auth, poweredBy }
|
|
66
67
|
* @param {object} options.primevue - PrimeVue config { plugin, theme, options }
|
|
@@ -101,7 +102,7 @@ export class Kernel {
|
|
|
101
102
|
* Create Vue Router with auth guard
|
|
102
103
|
*/
|
|
103
104
|
_createRouter() {
|
|
104
|
-
const { pages, homeRoute, coreRoutes, basePath, authAdapter } = this.options
|
|
105
|
+
const { pages, homeRoute, coreRoutes, basePath, hashMode, authAdapter } = this.options
|
|
105
106
|
|
|
106
107
|
// Validate required pages
|
|
107
108
|
if (!pages?.login) {
|
|
@@ -144,7 +145,7 @@ export class Kernel {
|
|
|
144
145
|
]
|
|
145
146
|
|
|
146
147
|
this.router = createRouter({
|
|
147
|
-
history: createWebHistory(basePath),
|
|
148
|
+
history: hashMode ? createWebHashHistory(basePath) : createWebHistory(basePath),
|
|
148
149
|
routes
|
|
149
150
|
})
|
|
150
151
|
|
|
@@ -209,6 +210,10 @@ export class Kernel {
|
|
|
209
210
|
// Router
|
|
210
211
|
app.use(this.router)
|
|
211
212
|
|
|
213
|
+
// Extract home route name for breadcrumb
|
|
214
|
+
const { homeRoute } = this.options
|
|
215
|
+
const homeRouteName = typeof homeRoute === 'object' ? homeRoute.name : homeRoute
|
|
216
|
+
|
|
212
217
|
// qdadm plugin
|
|
213
218
|
app.use(createQdadm({
|
|
214
219
|
orchestrator: this.orchestrator,
|
|
@@ -217,6 +222,7 @@ export class Kernel {
|
|
|
217
222
|
router: this.router,
|
|
218
223
|
toast: app.config.globalProperties.$toast,
|
|
219
224
|
app: this.options.app,
|
|
225
|
+
homeRoute: homeRouteName,
|
|
220
226
|
features: {
|
|
221
227
|
auth: !!authAdapter,
|
|
222
228
|
poweredBy: true,
|
package/src/plugin.js
CHANGED
|
@@ -22,6 +22,7 @@ import qdadmLogo from './assets/logo.svg'
|
|
|
22
22
|
* @param {object} options.features - Optional: Feature toggles (auth, poweredBy)
|
|
23
23
|
* @param {object} options.builtinModules - Optional: Builtin modules configuration
|
|
24
24
|
* @param {object} options.endpoints - Optional: API endpoints configuration
|
|
25
|
+
* @param {string} options.homeRoute - Optional: Home route name for breadcrumb
|
|
25
26
|
* @returns {object} Vue plugin
|
|
26
27
|
*/
|
|
27
28
|
export function createQdadm(options) {
|
|
@@ -112,6 +113,10 @@ export function createQdadm(options) {
|
|
|
112
113
|
app.provide('qdadmSectionOrder', options.modules.sectionOrder)
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
if (options.homeRoute) {
|
|
117
|
+
app.provide('qdadmHomeRoute', options.homeRoute)
|
|
118
|
+
}
|
|
119
|
+
|
|
115
120
|
// Add route guard for entity permissions
|
|
116
121
|
options.router.beforeEach((to, from, next) => {
|
|
117
122
|
const entity = to.meta?.entity
|