qdadm 0.15.1 → 0.17.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/README.md +153 -1
- package/package.json +15 -2
- package/src/components/BoolCell.vue +11 -6
- package/src/components/forms/FormField.vue +64 -6
- package/src/components/forms/FormPage.vue +276 -0
- package/src/components/index.js +11 -0
- package/src/components/layout/AppLayout.vue +18 -9
- package/src/components/layout/BaseLayout.vue +183 -0
- package/src/components/layout/DashboardLayout.vue +100 -0
- package/src/components/layout/FormLayout.vue +261 -0
- package/src/components/layout/ListLayout.vue +334 -0
- package/src/components/layout/PageHeader.vue +6 -9
- package/src/components/layout/PageNav.vue +15 -0
- package/src/components/layout/Zone.vue +165 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
- package/src/components/layout/defaults/DefaultFooter.vue +56 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
- package/src/components/layout/defaults/DefaultHeader.vue +69 -0
- package/src/components/layout/defaults/DefaultMenu.vue +197 -0
- package/src/components/layout/defaults/DefaultPagination.vue +79 -0
- package/src/components/layout/defaults/DefaultTable.vue +130 -0
- package/src/components/layout/defaults/DefaultToaster.vue +16 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
- package/src/components/layout/defaults/index.js +17 -0
- package/src/composables/index.js +8 -6
- package/src/composables/useBreadcrumb.js +9 -5
- package/src/composables/useForm.js +135 -0
- package/src/composables/useFormPageBuilder.js +1154 -0
- package/src/composables/useHooks.js +53 -0
- package/src/composables/useLayoutResolver.js +260 -0
- package/src/composables/useListPageBuilder.js +336 -52
- package/src/composables/useNavContext.js +372 -0
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/usePageTitle.js +59 -0
- package/src/composables/useSignals.js +49 -0
- package/src/composables/useZoneRegistry.js +162 -0
- package/src/core/bundles.js +406 -0
- package/src/core/decorator.js +322 -0
- package/src/core/extension.js +386 -0
- package/src/core/index.js +28 -0
- package/src/entity/EntityManager.js +314 -16
- package/src/entity/auth/AuthAdapter.js +125 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/index.js +11 -0
- package/src/entity/index.js +3 -0
- package/src/entity/storage/MockApiStorage.js +349 -0
- package/src/entity/storage/SdkStorage.js +478 -0
- package/src/entity/storage/index.js +2 -0
- package/src/hooks/HookRegistry.js +411 -0
- package/src/hooks/index.js +12 -0
- package/src/index.js +12 -0
- package/src/kernel/Kernel.js +141 -4
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +124 -6
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/plugin.js +5 -0
- package/src/zones/ZoneRegistry.js +821 -0
- package/src/zones/index.js +16 -0
- package/src/zones/zones.js +189 -0
- package/src/composables/useEntityTitle.js +0 -121
- package/src/composables/useManager.js +0 -20
- package/src/composables/usePageBuilder.js +0 -334
- package/src/composables/useStatus.js +0 -146
- package/src/composables/useSubEditor.js +0 -165
- package/src/composables/useTabSync.js +0 -110
|
@@ -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
|
+
}
|
|
@@ -4,19 +4,38 @@
|
|
|
4
4
|
* Provides reactive navigation state from moduleRegistry.
|
|
5
5
|
* All data comes from module init declarations.
|
|
6
6
|
* Nav items are filtered based on EntityManager.canRead() permissions.
|
|
7
|
+
*
|
|
8
|
+
* Invokes 'menu:alter' hook on first access to allow modules to modify
|
|
9
|
+
* the navigation structure dynamically.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import { computed, inject } from 'vue'
|
|
12
|
+
import { computed, inject, ref, onMounted } from 'vue'
|
|
10
13
|
import { useRoute, useRouter } from 'vue-router'
|
|
11
|
-
import { getNavSections, isRouteInFamily } from '../module/moduleRegistry'
|
|
14
|
+
import { getNavSections, isRouteInFamily, alterMenuSections, isMenuAltered } from '../module/moduleRegistry'
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Navigation composable
|
|
18
|
+
*
|
|
19
|
+
* @returns {object} Navigation state and helpers
|
|
20
|
+
* @property {import('vue').ComputedRef<Array>} navSections - Navigation sections (filtered by permissions)
|
|
21
|
+
* @property {Function} isNavActive - Check if nav item is active
|
|
22
|
+
* @property {Function} sectionHasActiveItem - Check if section has active item
|
|
23
|
+
* @property {Function} handleNavClick - Handle nav item click
|
|
24
|
+
* @property {import('vue').ComputedRef<string>} currentRouteName - Current route name
|
|
25
|
+
* @property {import('vue').ComputedRef<string>} currentRoutePath - Current route path
|
|
26
|
+
* @property {import('vue').Ref<boolean>} isReady - Whether menu:alter has completed
|
|
15
27
|
*/
|
|
16
28
|
export function useNavigation() {
|
|
17
29
|
const route = useRoute()
|
|
18
30
|
const router = useRouter()
|
|
19
31
|
const orchestrator = inject('qdadmOrchestrator', null)
|
|
32
|
+
const hooks = inject('qdadmHooks', null)
|
|
33
|
+
|
|
34
|
+
// Track whether menu:alter has completed
|
|
35
|
+
const isReady = ref(isMenuAltered())
|
|
36
|
+
|
|
37
|
+
// Trigger version to force reactivity after alteration
|
|
38
|
+
const alterVersion = ref(0)
|
|
20
39
|
|
|
21
40
|
/**
|
|
22
41
|
* Check if user can access a nav item based on its entity's canRead()
|
|
@@ -28,8 +47,22 @@ export function useNavigation() {
|
|
|
28
47
|
return manager.canRead()
|
|
29
48
|
}
|
|
30
49
|
|
|
50
|
+
// Invoke menu:alter hook on mount
|
|
51
|
+
onMounted(async () => {
|
|
52
|
+
if (!isMenuAltered()) {
|
|
53
|
+
await alterMenuSections(hooks)
|
|
54
|
+
alterVersion.value++
|
|
55
|
+
isReady.value = true
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
31
59
|
// Get nav sections from registry, filtering items based on permissions
|
|
60
|
+
// Depends on alterVersion to trigger re-computation after alteration
|
|
32
61
|
const navSections = computed(() => {
|
|
62
|
+
// Force dependency on alterVersion for reactivity
|
|
63
|
+
// eslint-disable-next-line no-unused-expressions
|
|
64
|
+
alterVersion.value
|
|
65
|
+
|
|
33
66
|
const sections = getNavSections()
|
|
34
67
|
return sections
|
|
35
68
|
.map(section => ({
|
|
@@ -75,6 +108,9 @@ export function useNavigation() {
|
|
|
75
108
|
// Data (from moduleRegistry)
|
|
76
109
|
navSections,
|
|
77
110
|
|
|
111
|
+
// Ready state (menu:alter completed)
|
|
112
|
+
isReady,
|
|
113
|
+
|
|
78
114
|
// Active state
|
|
79
115
|
isNavActive,
|
|
80
116
|
sectionHasActiveItem,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSignals - Access the signal bus for event-driven communication
|
|
3
|
+
*
|
|
4
|
+
* Provides access to the SignalBus created by Kernel during bootstrap.
|
|
5
|
+
* Components can subscribe to signals without direct imports.
|
|
6
|
+
*
|
|
7
|
+
* @returns {SignalBus|null} - The signal bus instance or null if not available
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Basic usage
|
|
11
|
+
* const signals = useSignals()
|
|
12
|
+
* signals.on('entity:created', (payload) => {
|
|
13
|
+
* console.log('Entity created:', payload.entity, payload.data)
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Subscribe to specific entity
|
|
18
|
+
* const signals = useSignals()
|
|
19
|
+
* signals.on('books:updated', ({ data }) => {
|
|
20
|
+
* refreshBookList()
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Wildcard subscription
|
|
25
|
+
* const signals = useSignals()
|
|
26
|
+
* signals.on('*:deleted', ({ entity, data }) => {
|
|
27
|
+
* showDeletedNotification(entity)
|
|
28
|
+
* })
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // Auto-cleanup on unmount
|
|
32
|
+
* import { onUnmounted } from 'vue'
|
|
33
|
+
*
|
|
34
|
+
* const signals = useSignals()
|
|
35
|
+
* const unbind = signals.on('entity:created', handler)
|
|
36
|
+
* onUnmounted(() => unbind())
|
|
37
|
+
*/
|
|
38
|
+
import { inject } from 'vue'
|
|
39
|
+
|
|
40
|
+
export function useSignals() {
|
|
41
|
+
const signals = inject('qdadmSignals')
|
|
42
|
+
|
|
43
|
+
if (!signals) {
|
|
44
|
+
console.warn('[qdadm] useSignals: signal bus not available. Ensure Kernel is initialized.')
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return signals
|
|
49
|
+
}
|