qdadm 0.30.0 → 0.32.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 (47) hide show
  1. package/package.json +2 -1
  2. package/src/components/forms/FormPage.vue +1 -1
  3. package/src/components/layout/AppLayout.vue +13 -1
  4. package/src/components/layout/Zone.vue +40 -23
  5. package/src/composables/index.js +1 -0
  6. package/src/composables/useAuth.js +44 -4
  7. package/src/composables/useCurrentEntity.js +44 -0
  8. package/src/composables/useFormPageBuilder.js +3 -3
  9. package/src/composables/useNavContext.js +24 -8
  10. package/src/debug/AuthCollector.js +340 -0
  11. package/src/debug/Collector.js +235 -0
  12. package/src/debug/DebugBridge.js +163 -0
  13. package/src/debug/DebugModule.js +215 -0
  14. package/src/debug/EntitiesCollector.js +403 -0
  15. package/src/debug/ErrorCollector.js +66 -0
  16. package/src/debug/LocalStorageAdapter.js +150 -0
  17. package/src/debug/SignalCollector.js +87 -0
  18. package/src/debug/ToastCollector.js +82 -0
  19. package/src/debug/ZonesCollector.js +300 -0
  20. package/src/debug/components/DebugBar.vue +1232 -0
  21. package/src/debug/components/ObjectTree.vue +194 -0
  22. package/src/debug/components/index.js +8 -0
  23. package/src/debug/components/panels/AuthPanel.vue +174 -0
  24. package/src/debug/components/panels/EntitiesPanel.vue +712 -0
  25. package/src/debug/components/panels/EntriesPanel.vue +188 -0
  26. package/src/debug/components/panels/ToastsPanel.vue +112 -0
  27. package/src/debug/components/panels/ZonesPanel.vue +232 -0
  28. package/src/debug/components/panels/index.js +8 -0
  29. package/src/debug/index.js +31 -0
  30. package/src/entity/EntityManager.js +142 -20
  31. package/src/entity/auth/CompositeAuthAdapter.js +212 -0
  32. package/src/entity/auth/factory.js +207 -0
  33. package/src/entity/auth/factory.test.js +257 -0
  34. package/src/entity/auth/index.js +14 -0
  35. package/src/entity/storage/MockApiStorage.js +51 -2
  36. package/src/entity/storage/index.js +9 -2
  37. package/src/index.js +7 -0
  38. package/src/kernel/Kernel.js +468 -48
  39. package/src/kernel/KernelContext.js +385 -0
  40. package/src/kernel/Module.js +111 -0
  41. package/src/kernel/ModuleLoader.js +573 -0
  42. package/src/kernel/SignalBus.js +2 -7
  43. package/src/kernel/index.js +14 -0
  44. package/src/toast/ToastBridgeModule.js +70 -0
  45. package/src/toast/ToastListener.vue +47 -0
  46. package/src/toast/index.js +15 -0
  47. package/src/toast/useSignalToast.js +113 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "./editors": "./src/editors/index.js",
26
26
  "./module": "./src/module/index.js",
27
27
  "./utils": "./src/utils/index.js",
28
+ "./debug": "./src/debug/index.js",
28
29
  "./styles": "./src/styles/index.scss",
29
30
  "./styles/breakpoints": "./src/styles/_breakpoints.scss",
30
31
  "./gen": "./src/gen/index.js",
@@ -16,7 +16,7 @@
16
16
  * form.generateFields()
17
17
  * form.addSaveAction()
18
18
  *
19
- * <FormPage v-bind="form.props" v-on="form.events">
19
+ * <FormPage v-bind="form.props.value" v-on="form.events">
20
20
  * <template #fields>
21
21
  * <FormField v-model="form.data.title" name="title" />
22
22
  * </template>
@@ -157,8 +157,20 @@ function handleLogout() {
157
157
  const slots = useSlots()
158
158
  const hasSlotContent = computed(() => !!slots.default)
159
159
 
160
+ // Current page entity data - pages can inject and set this to avoid double fetch
161
+ // When a page loads an entity, it sets this ref, and useNavContext uses it for breadcrumb
162
+ const currentEntityData = ref(null)
163
+ provide('qdadmCurrentEntityData', currentEntityData)
164
+
165
+ // Clear entity data on route change (before new page mounts)
166
+ watch(() => route.fullPath, () => {
167
+ currentEntityData.value = null
168
+ })
169
+
160
170
  // Navigation context (breadcrumb + navlinks from route config)
161
- const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext()
171
+ const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext({
172
+ entityData: currentEntityData
173
+ })
162
174
 
163
175
  // Allow child pages to override breadcrumb/navlinks via provide/inject
164
176
  const breadcrumbOverride = ref(null)
@@ -87,16 +87,22 @@ const defaultComp = computed(() => {
87
87
  * This creates a functional component that builds the nested structure
88
88
  * once per block, avoiding the infinite re-render issue.
89
89
  *
90
+ * Slot content from the Zone is passed to the innermost block component,
91
+ * allowing the block to render Zone's children (e.g., form fields).
92
+ *
90
93
  * @param {object} block - Block config with wrappers array
91
94
  * @param {object} mergedProps - Props to pass to the innermost component
95
+ * @param {Function} slotFn - Slot function to pass to innermost component
92
96
  * @returns {object} - Vue component definition
93
97
  */
94
- function createWrappedComponent(block, mergedProps) {
98
+ function createWrappedComponent(block, mergedProps, slotFn) {
95
99
  return defineComponent({
96
100
  name: 'WrappedBlock',
97
101
  render() {
98
102
  // Start with the innermost component (the original block)
99
- let current = h(block.component, mergedProps)
103
+ // Pass Zone's slot content to allow block to render it
104
+ const innerSlots = slotFn ? { default: slotFn } : {}
105
+ let current = h(block.component, mergedProps, innerSlots)
100
106
 
101
107
  // Build from inside out: last wrapper in array wraps the innermost
102
108
  // Wrappers are already sorted by weight (lower = outer)
@@ -118,14 +124,19 @@ function createWrappedComponent(block, mergedProps) {
118
124
  /**
119
125
  * Computed map of wrapped block components
120
126
  * Memoizes the wrapped components to avoid re-creating them on every render
127
+ *
128
+ * Note: Slot function is passed to allow Zone's children to be rendered
129
+ * inside the innermost block component.
121
130
  */
122
131
  const wrappedComponents = computed(() => {
123
132
  const map = new Map()
133
+ // Get slot function to pass to innermost block
134
+ const slotFn = slots.default
124
135
  for (const block of blocks.value) {
125
136
  if (block.wrappers && block.wrappers.length > 0) {
126
137
  const key = block.id || block.weight
127
138
  const mergedProps = { ...props.blockProps, ...block.props }
128
- map.set(key, createWrappedComponent(block, mergedProps))
139
+ map.set(key, createWrappedComponent(block, mergedProps, slotFn))
129
140
  }
130
141
  }
131
142
  return map
@@ -141,25 +152,31 @@ function getWrappedComponent(block) {
141
152
  </script>
142
153
 
143
154
  <template>
144
- <template v-if="hasBlocks">
145
- <template v-for="block in blocks" :key="block.id || block.weight">
146
- <!-- Render wrapped blocks using memoized component -->
147
- <component
148
- v-if="block.wrappers"
149
- :is="getWrappedComponent(block)"
150
- />
151
- <!-- Render simple blocks directly -->
152
- <component
153
- v-else
154
- :is="block.component"
155
- v-bind="{ ...blockProps, ...block.props }"
156
- />
155
+ <div :data-zone="name" class="qdadm-zone">
156
+ <template v-if="hasBlocks">
157
+ <template v-for="block in blocks" :key="block.id || block.weight">
158
+ <!-- Render wrapped blocks using memoized component -->
159
+ <component
160
+ v-if="block.wrappers"
161
+ :is="getWrappedComponent(block)"
162
+ />
163
+ <!-- Render simple blocks directly, passing Zone's slot content -->
164
+ <component
165
+ v-else
166
+ :is="block.component"
167
+ v-bind="{ ...blockProps, ...block.props }"
168
+ >
169
+ <slot />
170
+ </component>
171
+ </template>
157
172
  </template>
158
- </template>
159
- <component
160
- v-else-if="showDefault"
161
- :is="defaultComp"
162
- v-bind="blockProps"
163
- />
164
- <slot v-else-if="showSlot" />
173
+ <component
174
+ v-else-if="showDefault"
175
+ :is="defaultComp"
176
+ v-bind="blockProps"
177
+ >
178
+ <slot />
179
+ </component>
180
+ <slot v-else-if="showSlot" />
181
+ </div>
165
182
  </template>
@@ -12,6 +12,7 @@ export { useListPageBuilder, PAGE_SIZE_OPTIONS } from './useListPageBuilder'
12
12
  export { usePageTitle } from './usePageTitle'
13
13
  export { useApp } from './useApp'
14
14
  export { useAuth } from './useAuth'
15
+ export { useCurrentEntity } from './useCurrentEntity'
15
16
  export { useNavContext } from './useNavContext'
16
17
  export { useNavigation } from './useNavigation'
17
18
  export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
@@ -1,9 +1,17 @@
1
1
  /**
2
- * useAuth - Access authentication state
2
+ * useAuth - Access authentication state (component-only)
3
3
  *
4
4
  * Provides access to authAdapter set via createQdadm bootstrap.
5
5
  * Returns neutral values if auth is disabled.
6
6
  *
7
+ * IMPORTANT: Must be called within component setup() function.
8
+ * For route guards or services, use authAdapter directly.
9
+ *
10
+ * Reactivity:
11
+ * - Listens to auth:login, auth:logout, auth:impersonate, auth:impersonate:stop signals
12
+ * - User computed re-evaluates when these signals fire
13
+ * - No polling or manual refresh needed
14
+ *
7
15
  * Note: Permission checking (canRead/canWrite) is handled by EntityManager,
8
16
  * not by useAuth. This keeps auth simple and delegates permission logic
9
17
  * to where it belongs (the entity layer).
@@ -12,11 +20,18 @@
12
20
  * const { isAuthenticated, user, logout } = useAuth()
13
21
  */
14
22
 
15
- import { inject, computed, ref } from 'vue'
23
+ import { inject, computed, ref, onUnmounted, getCurrentInstance } from 'vue'
16
24
 
17
25
  export function useAuth() {
26
+ // Strict context check - must be called in component setup()
27
+ const instance = getCurrentInstance()
28
+ if (!instance) {
29
+ throw new Error('[qdadm] useAuth() must be called within component setup()')
30
+ }
31
+
18
32
  const auth = inject('authAdapter')
19
33
  const features = inject('qdadmFeatures')
34
+ const signals = inject('qdadmSignals', null)
20
35
 
21
36
  // If auth disabled or not provided, return neutral values
22
37
  if (!features?.auth || !auth) {
@@ -30,8 +45,28 @@ export function useAuth() {
30
45
  }
31
46
  }
32
47
 
33
- // Reactive user state
48
+ // Reactive trigger - incremented on auth signals to force computed re-evaluation
49
+ const authTick = ref(0)
50
+
51
+ // Subscribe to auth signals for reactivity
52
+ const cleanups = []
53
+ if (signals) {
54
+ cleanups.push(signals.on('auth:login', () => { authTick.value++ }))
55
+ cleanups.push(signals.on('auth:logout', () => { authTick.value++ }))
56
+ cleanups.push(signals.on('auth:impersonate', () => { authTick.value++ }))
57
+ cleanups.push(signals.on('auth:impersonate:stop', () => { authTick.value++ }))
58
+ }
59
+
60
+ // Cleanup signal subscriptions on unmount
61
+ onUnmounted(() => {
62
+ cleanups.forEach(cleanup => cleanup())
63
+ })
64
+
65
+ // Reactive user state - re-evaluates when authTick changes
34
66
  const user = computed(() => {
67
+ // Touch authTick to create reactive dependency
68
+ // eslint-disable-next-line no-unused-expressions
69
+ authTick.value
35
70
  if (typeof auth.getUser === 'function') {
36
71
  return auth.getUser()
37
72
  }
@@ -42,7 +77,12 @@ export function useAuth() {
42
77
  login: auth.login,
43
78
  logout: auth.logout,
44
79
  getCurrentUser: auth.getCurrentUser,
45
- isAuthenticated: computed(() => auth.isAuthenticated()),
80
+ isAuthenticated: computed(() => {
81
+ // Touch authTick for reactivity
82
+ // eslint-disable-next-line no-unused-expressions
83
+ authTick.value
84
+ return auth.isAuthenticated()
85
+ }),
46
86
  user,
47
87
  authEnabled: true
48
88
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * useCurrentEntity - Share page entity data with navigation context
3
+ *
4
+ * When a page loads an entity (e.g., ProductDetailPage fetches a product),
5
+ * it can call setCurrentEntity() to share that data with the navigation context.
6
+ * This avoids a second fetch for breadcrumb display.
7
+ *
8
+ * Usage in a detail page:
9
+ * ```js
10
+ * const { setCurrentEntity } = useCurrentEntity()
11
+ *
12
+ * async function loadProduct() {
13
+ * product.value = await productsManager.get(productId)
14
+ * setCurrentEntity(product.value) // Share with navigation
15
+ * }
16
+ * ```
17
+ *
18
+ * The navigation context (useNavContext) will use this data instead of
19
+ * fetching the entity again for the breadcrumb.
20
+ */
21
+ import { inject } from 'vue'
22
+
23
+ /**
24
+ * Composable to share current page entity with navigation context
25
+ * @returns {{ setCurrentEntity: (data: object) => void }}
26
+ */
27
+ export function useCurrentEntity() {
28
+ const currentEntityData = inject('qdadmCurrentEntityData', null)
29
+
30
+ /**
31
+ * Set the current entity data for navigation context
32
+ * Call this after loading an entity to avoid double fetch
33
+ * @param {object} data - Entity data
34
+ */
35
+ function setCurrentEntity(data) {
36
+ if (currentEntityData) {
37
+ currentEntityData.value = data
38
+ }
39
+ }
40
+
41
+ return {
42
+ setCurrentEntity
43
+ }
44
+ }
@@ -17,7 +17,7 @@
17
17
  * form.addSaveAction()
18
18
  * form.addDeleteAction()
19
19
  *
20
- * <FormPage v-bind="form.props" v-on="form.events">
20
+ * <FormPage v-bind="form.props.value" v-on="form.events">
21
21
  * <template #fields>
22
22
  * <FormField v-model="form.data.title" name="title" />
23
23
  * </template>
@@ -1011,7 +1011,7 @@ export function useFormPageBuilder(config = {}) {
1011
1011
 
1012
1012
  /**
1013
1013
  * Props object for FormPage component
1014
- * Use with v-bind: <FormPage v-bind="form.props">
1014
+ * Use with v-bind: <FormPage v-bind="form.props.value">
1015
1015
  */
1016
1016
  const formProps = computed(() => ({
1017
1017
  // Mode
@@ -1049,7 +1049,7 @@ export function useFormPageBuilder(config = {}) {
1049
1049
 
1050
1050
  /**
1051
1051
  * Event handlers for FormPage
1052
- * Use with v-on: <FormPage v-bind="form.props" v-on="form.events">
1052
+ * Use with v-on: <FormPage v-bind="form.props.value" v-on="form.events">
1053
1053
  */
1054
1054
  const formEvents = {
1055
1055
  save: () => submit(false),
@@ -207,30 +207,46 @@ export function useNavContext(options = {}) {
207
207
 
208
208
  /**
209
209
  * Fetch entity data for all 'item' segments in the chain
210
+ *
211
+ * For the LAST item (current entity):
212
+ * - Uses entityData if provided by the page via setCurrentEntity()
213
+ * - Does NOT fetch automatically - page is responsible for providing data
214
+ * - Breadcrumb shows "..." until page provides the data
215
+ *
216
+ * For PARENT items: always fetches from manager
210
217
  */
211
- watch([navChain, () => options.entityData], async ([chain]) => {
212
- chainData.value.clear()
213
- const externalData = unref(options.entityData)
218
+ // Watch navChain and entityData ref (deep watch to catch value changes)
219
+ const entityDataRef = computed(() => options.entityData ? unref(options.entityData) : null)
220
+ watch([navChain, entityDataRef], async ([chain, externalData]) => {
221
+ // Build new Map (reassignment triggers Vue reactivity, Map.set() doesn't)
222
+ const newChainData = new Map()
214
223
 
215
224
  for (let i = 0; i < chain.length; i++) {
216
225
  const segment = chain[i]
217
226
  if (segment.type !== 'item') continue
218
227
 
219
- // For the last item, use external data if provided (from useForm)
220
228
  const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
221
- if (isLastItem && externalData) {
222
- chainData.value.set(i, externalData)
229
+
230
+ // For the last item, use external data from page (don't fetch)
231
+ if (isLastItem) {
232
+ if (externalData) {
233
+ newChainData.set(i, externalData)
234
+ }
235
+ // If no externalData, breadcrumb will show "..." until page provides it
223
236
  continue
224
237
  }
225
238
 
226
- // Fetch from manager
239
+ // For parent items, fetch from manager
227
240
  try {
228
241
  const data = await segment.manager.get(segment.id)
229
- chainData.value.set(i, data)
242
+ newChainData.set(i, data)
230
243
  } catch (e) {
231
244
  console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
232
245
  }
233
246
  }
247
+
248
+ // Assign new Map to trigger reactivity
249
+ chainData.value = newChainData
234
250
  }, { immediate: true, deep: true })
235
251
 
236
252
  // ============================================================================