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.
Files changed (66) hide show
  1. package/README.md +153 -1
  2. package/package.json +15 -2
  3. package/src/components/BoolCell.vue +11 -6
  4. package/src/components/forms/FormField.vue +64 -6
  5. package/src/components/forms/FormPage.vue +276 -0
  6. package/src/components/index.js +11 -0
  7. package/src/components/layout/AppLayout.vue +18 -9
  8. package/src/components/layout/BaseLayout.vue +183 -0
  9. package/src/components/layout/DashboardLayout.vue +100 -0
  10. package/src/components/layout/FormLayout.vue +261 -0
  11. package/src/components/layout/ListLayout.vue +334 -0
  12. package/src/components/layout/PageHeader.vue +6 -9
  13. package/src/components/layout/PageNav.vue +15 -0
  14. package/src/components/layout/Zone.vue +165 -0
  15. package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
  16. package/src/components/layout/defaults/DefaultFooter.vue +56 -0
  17. package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
  18. package/src/components/layout/defaults/DefaultHeader.vue +69 -0
  19. package/src/components/layout/defaults/DefaultMenu.vue +197 -0
  20. package/src/components/layout/defaults/DefaultPagination.vue +79 -0
  21. package/src/components/layout/defaults/DefaultTable.vue +130 -0
  22. package/src/components/layout/defaults/DefaultToaster.vue +16 -0
  23. package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
  24. package/src/components/layout/defaults/index.js +17 -0
  25. package/src/composables/index.js +8 -6
  26. package/src/composables/useBreadcrumb.js +9 -5
  27. package/src/composables/useForm.js +135 -0
  28. package/src/composables/useFormPageBuilder.js +1154 -0
  29. package/src/composables/useHooks.js +53 -0
  30. package/src/composables/useLayoutResolver.js +260 -0
  31. package/src/composables/useListPageBuilder.js +336 -52
  32. package/src/composables/useNavContext.js +372 -0
  33. package/src/composables/useNavigation.js +38 -2
  34. package/src/composables/usePageTitle.js +59 -0
  35. package/src/composables/useSignals.js +49 -0
  36. package/src/composables/useZoneRegistry.js +162 -0
  37. package/src/core/bundles.js +406 -0
  38. package/src/core/decorator.js +322 -0
  39. package/src/core/extension.js +386 -0
  40. package/src/core/index.js +28 -0
  41. package/src/entity/EntityManager.js +314 -16
  42. package/src/entity/auth/AuthAdapter.js +125 -0
  43. package/src/entity/auth/PermissiveAdapter.js +64 -0
  44. package/src/entity/auth/index.js +11 -0
  45. package/src/entity/index.js +3 -0
  46. package/src/entity/storage/MockApiStorage.js +349 -0
  47. package/src/entity/storage/SdkStorage.js +478 -0
  48. package/src/entity/storage/index.js +2 -0
  49. package/src/hooks/HookRegistry.js +411 -0
  50. package/src/hooks/index.js +12 -0
  51. package/src/index.js +12 -0
  52. package/src/kernel/Kernel.js +141 -4
  53. package/src/kernel/SignalBus.js +180 -0
  54. package/src/kernel/index.js +7 -0
  55. package/src/module/moduleRegistry.js +124 -6
  56. package/src/orchestrator/Orchestrator.js +73 -1
  57. package/src/plugin.js +5 -0
  58. package/src/zones/ZoneRegistry.js +821 -0
  59. package/src/zones/index.js +16 -0
  60. package/src/zones/zones.js +189 -0
  61. package/src/composables/useEntityTitle.js +0 -121
  62. package/src/composables/useManager.js +0 -20
  63. package/src/composables/usePageBuilder.js +0 -334
  64. package/src/composables/useStatus.js +0 -146
  65. package/src/composables/useSubEditor.js +0 -165
  66. 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
+ }