qdadm 0.49.0 → 0.50.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -177,9 +177,12 @@ function setBreadcrumbEntity(data, level = 1) {
177
177
  provide('qdadmSetBreadcrumbEntity', setBreadcrumbEntity)
178
178
  provide('qdadmBreadcrumbEntities', breadcrumbEntities)
179
179
 
180
- // Clear entity data on route change (before new page mounts)
180
+ // Clear entity data and overrides on route change (before new page mounts)
181
+ // This ensures list pages get default breadcrumb, detail pages can override via PageNav
181
182
  watch(() => route.fullPath, () => {
182
183
  breadcrumbEntities.value = new Map()
184
+ breadcrumbOverride.value = null
185
+ navlinksOverride.value = null
183
186
  })
184
187
 
185
188
  // Navigation context (breadcrumb + navlinks from route config)
@@ -26,10 +26,14 @@ import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
26
26
  const breadcrumbOverride = inject('qdadmBreadcrumbOverride', null)
27
27
  const navlinksOverride = inject('qdadmNavlinksOverride', null)
28
28
  const homeRouteName = inject('qdadmHomeRoute', null)
29
+ // Entity data set by useEntityItemPage via setBreadcrumbEntity
30
+ const breadcrumbEntities = inject('qdadmBreadcrumbEntities', null)
29
31
 
30
32
  const props = defineProps({
31
33
  entity: { type: Object, default: null },
32
- parentEntity: { type: Object, default: null }
34
+ parentEntity: { type: Object, default: null },
35
+ // Show "Details" link in navlinks (default: false since breadcrumb shows current page)
36
+ showDetailsLink: { type: Boolean, default: false }
33
37
  })
34
38
 
35
39
  const route = useRoute()
@@ -85,10 +89,22 @@ const breadcrumbItems = computed(() => {
85
89
  if (entityName) {
86
90
  const manager = getManager(entityName)
87
91
  if (manager) {
92
+ // Entity list link
88
93
  items.push({
89
94
  label: manager.labelPlural || manager.name,
90
95
  to: { name: manager.routePrefix }
91
96
  })
97
+
98
+ // If on detail page (has :id param), add current entity item
99
+ const entityId = route.params.id
100
+ if (entityId) {
101
+ // Get entity data from props or from breadcrumbEntities (set by useEntityItemPage)
102
+ const entityData = props.entity || breadcrumbEntities?.value?.get(1)
103
+ const entityLabel = entityData
104
+ ? manager.getEntityLabel(entityData)
105
+ : '...'
106
+ items.push({ label: entityLabel })
107
+ }
92
108
  }
93
109
  }
94
110
  return items
@@ -107,8 +123,10 @@ const breadcrumbItems = computed(() => {
107
123
  })
108
124
 
109
125
  // Parent item (with label from data)
110
- const parentLabel = parentData.value
111
- ? parentManager.getEntityLabel(parentData.value)
126
+ // Prefer breadcrumbEntities (set by useEntityItemPage) over local parentData
127
+ const parentEntityData = breadcrumbEntities?.value?.get(1) || parentData.value
128
+ const parentLabel = parentEntityData
129
+ ? parentManager.getEntityLabel(parentEntityData)
112
130
  : '...'
113
131
  const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
114
132
  const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
@@ -135,8 +153,16 @@ const siblingNavlinks = computed(() => {
135
153
  const { entity: parentEntityName, param } = parentConfig.value
136
154
  const siblings = getSiblingRoutes(parentEntityName, param)
137
155
 
156
+ // Filter out action routes (create, edit) - they're not navigation tabs
157
+ const navRoutes = siblings.filter(r => {
158
+ const name = r.name || ''
159
+ const path = r.path || ''
160
+ // Exclude routes ending with -create, -edit, -new or paths with /create, /edit, /new
161
+ return !name.match(/-(create|edit|new)$/) && !path.match(/\/(create|edit|new)$/)
162
+ })
163
+
138
164
  // Build navlinks with current route params
139
- return siblings.map(siblingRoute => {
165
+ return navRoutes.map(siblingRoute => {
140
166
  const manager = siblingRoute.meta?.entity ? getManager(siblingRoute.meta.entity) : null
141
167
  const label = siblingRoute.meta?.navLabel || manager?.labelPlural || siblingRoute.name
142
168
 
@@ -159,10 +185,19 @@ const childNavlinks = computed(() => {
159
185
  const children = getChildRoutes(entityName)
160
186
  if (children.length === 0) return []
161
187
 
188
+ // Filter out action routes (create, edit) - they're not navigation tabs
189
+ const navRoutes = children.filter(r => {
190
+ const name = r.name || ''
191
+ const path = r.path || ''
192
+ return !name.match(/-(create|edit|new)$/) && !path.match(/\/(create|edit|new)$/)
193
+ })
194
+
195
+ if (navRoutes.length === 0) return []
196
+
162
197
  const entityId = route.params.id
163
198
 
164
199
  // Build navlinks to child routes
165
- return children.map(childRoute => {
200
+ return navRoutes.map(childRoute => {
166
201
  const childManager = childRoute.meta?.entity ? getManager(childRoute.meta.entity) : null
167
202
  const label = childRoute.meta?.navLabel || childManager?.labelPlural || childRoute.name
168
203
  const parentParam = childRoute.meta?.parent?.param || 'id'
@@ -177,7 +212,7 @@ const childNavlinks = computed(() => {
177
212
 
178
213
  // Combined navlinks with "Details" link
179
214
  const allNavlinks = computed(() => {
180
- // Case 1: On a child route - show siblings + Details link to parent
215
+ // Case 1: On a child route - show siblings + optional Details link to parent
181
216
  if (parentConfig.value) {
182
217
  const { entity: parentEntityName, param, itemRoute } = parentConfig.value
183
218
  const parentId = route.params[param]
@@ -185,43 +220,50 @@ const allNavlinks = computed(() => {
185
220
 
186
221
  if (!parentManager) return siblingNavlinks.value
187
222
 
188
- const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
189
- const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
190
- const isOnParentRoute = route.name === parentRouteName
191
-
192
- // Details link to parent item page
193
- // CONVENTION: Entity item routes MUST use :id as param name (e.g., /books/:id)
194
- // This is a qdadm convention - all entity detail/edit routes expect 'id' param
195
- const detailsLink = {
196
- label: 'Details',
197
- to: { name: parentRouteName, params: { id: parentId } },
198
- active: isOnParentRoute
223
+ // Details link is optional since breadcrumb already shows parent
224
+ if (props.showDetailsLink) {
225
+ const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
226
+ const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
227
+ const isOnParentRoute = route.name === parentRouteName
228
+
229
+ // Details link to parent item page
230
+ // CONVENTION: Entity item routes MUST use :id as param name (e.g., /books/:id)
231
+ const detailsLink = {
232
+ label: 'Details',
233
+ to: { name: parentRouteName, params: { id: parentId } },
234
+ active: isOnParentRoute
235
+ }
236
+
237
+ return [detailsLink, ...siblingNavlinks.value]
199
238
  }
200
239
 
201
- return [detailsLink, ...siblingNavlinks.value]
240
+ return siblingNavlinks.value
202
241
  }
203
242
 
204
- // Case 2: On parent detail page - show children + Details (active)
243
+ // Case 2: On parent detail page - show children + optional Details (active)
205
244
  if (childNavlinks.value.length > 0) {
206
- const detailsLink = {
207
- label: 'Details',
208
- to: { name: route.name, params: route.params },
209
- active: true
245
+ // Details link is optional since breadcrumb already shows current page
246
+ if (props.showDetailsLink) {
247
+ const detailsLink = {
248
+ label: 'Details',
249
+ to: { name: route.name, params: route.params },
250
+ active: true
251
+ }
252
+ return [detailsLink, ...childNavlinks.value]
210
253
  }
211
-
212
- return [detailsLink, ...childNavlinks.value]
254
+ return childNavlinks.value
213
255
  }
214
256
 
215
257
  return []
216
258
  })
217
259
 
218
260
  // Sync breadcrumb and navlinks to AppLayout via provide/inject
219
- // Watch computed values + route changes to ensure updates after navigation
220
- watch([breadcrumbItems, () => route.fullPath], ([items]) => {
261
+ // Watch computed values + route changes + entity data to ensure updates
262
+ watch([breadcrumbItems, () => route.fullPath, breadcrumbEntities], ([items]) => {
221
263
  if (breadcrumbOverride) {
222
264
  breadcrumbOverride.value = items
223
265
  }
224
- }, { immediate: true })
266
+ }, { immediate: true, deep: true })
225
267
 
226
268
  watch([allNavlinks, () => route.fullPath], ([links]) => {
227
269
  if (navlinksOverride) {
@@ -126,12 +126,18 @@ export function useEntityItemFormPage(config = {}) {
126
126
  getId
127
127
  })
128
128
 
129
- const { manager, orchestrator, entityId, setBreadcrumbEntity } = itemPage
129
+ const { manager, orchestrator, entityId, setBreadcrumbEntity, getInitialDataWithParent, parentConfig, parentId, parentData, parentChain, getChainDepth } = itemPage
130
130
 
131
131
  // Read config from manager with option overrides
132
132
  const entityName = config.entityName ?? manager.label
133
133
  const routePrefix = config.routePrefix ?? manager.routePrefix
134
- const initialData = config.initialData ?? manager.getInitialData()
134
+
135
+ // Initial data: merge user-provided with auto-populated parent foreignKey
136
+ // getInitialDataWithParent() adds the foreignKey from route.meta.parent
137
+ const baseInitialData = getInitialDataWithParent()
138
+ const initialData = config.initialData
139
+ ? { ...baseInitialData, ...config.initialData }
140
+ : baseInitialData
135
141
 
136
142
  /**
137
143
  * Detect form mode: 'create' or 'edit'
@@ -1094,6 +1100,13 @@ export function useEntityItemFormPage(config = {}) {
1094
1100
  isCreate,
1095
1101
  entityId,
1096
1102
 
1103
+ // Parent chain (from route.meta.parent, supports N-level nesting)
1104
+ parentConfig,
1105
+ parentId,
1106
+ parentData, // Immediate parent (level 1)
1107
+ parentChain, // All parents: Map(level -> data)
1108
+ getChainDepth,
1109
+
1097
1110
  // State
1098
1111
  data,
1099
1112
  loading,
@@ -6,6 +6,7 @@
6
6
  * - Loading/error state management
7
7
  * - Breadcrumb integration (auto-calls setBreadcrumbEntity)
8
8
  * - Manager access for child composables
9
+ * - **Parent chain auto-detection** from route.meta.parent
9
10
  *
10
11
  * Used as base for:
11
12
  * - Show pages (read-only detail pages)
@@ -21,6 +22,21 @@
21
22
  * // error.value contains error message if failed
22
23
  * ```
23
24
  *
25
+ * ## Parent Chain Auto-Detection
26
+ *
27
+ * When route.meta.parent is configured, the composable auto-loads the parent entity:
28
+ *
29
+ * ```js
30
+ * // Route: /authors/:id/books/create
31
+ * // route.meta.parent = { entity: 'authors', param: 'id', foreignKey: 'author_id' }
32
+ *
33
+ * const { parentData, parentId, getInitialDataWithParent } = useEntityItemPage({ entity: 'books' })
34
+ *
35
+ * // parentData.value = loaded author entity
36
+ * // parentId.value = route.params.id
37
+ * // getInitialDataWithParent() = { ...defaults, author_id: parentId }
38
+ * ```
39
+ *
24
40
  * ## Usage in parent composables
25
41
  *
26
42
  * ```js
@@ -45,6 +61,8 @@ export function useEntityItemPage(config = {}) {
45
61
  // Loading options
46
62
  loadOnMount = true,
47
63
  breadcrumb = true,
64
+ // Parent chain options
65
+ autoLoadParent = true, // Auto-load parent entity from route.meta.parent
48
66
  // ID extraction
49
67
  getId = null,
50
68
  idParam = 'id',
@@ -82,6 +100,135 @@ export function useEntityItemPage(config = {}) {
82
100
  const loading = ref(false)
83
101
  const error = ref(null)
84
102
 
103
+ // ============ PARENT CHAIN ============
104
+
105
+ /**
106
+ * Parent configuration from route.meta.parent
107
+ * Structure: { entity, param, foreignKey, parent?: {...} }
108
+ * Supports nested parents for N-level chains
109
+ */
110
+ const parentConfig = computed(() => route.meta?.parent || null)
111
+
112
+ /**
113
+ * Parent entity ID from route params (based on parentConfig.param)
114
+ */
115
+ const parentId = computed(() => {
116
+ if (!parentConfig.value) return null
117
+ return route.params[parentConfig.value.param] || null
118
+ })
119
+
120
+ /**
121
+ * Loaded parent entities data (Map: level -> data)
122
+ * Level 1 = immediate parent, Level 2 = grandparent, etc.
123
+ */
124
+ const parentChain = ref(new Map())
125
+ const parentLoading = ref(false)
126
+
127
+ /**
128
+ * Immediate parent data (shortcut for level 1)
129
+ */
130
+ const parentData = computed(() => parentChain.value.get(1) || null)
131
+
132
+ /**
133
+ * Calculate the depth of the current entity in the parent chain
134
+ * Returns number of parent levels + 1 for current entity
135
+ */
136
+ function getChainDepth(config = parentConfig.value) {
137
+ if (!config) return 1
138
+ let depth = 1
139
+ let current = config
140
+ while (current) {
141
+ depth++
142
+ current = current.parent
143
+ }
144
+ return depth
145
+ }
146
+
147
+ /**
148
+ * Get parent entity manager (if parent is configured)
149
+ */
150
+ function getParentManager() {
151
+ if (!parentConfig.value) return null
152
+ return orchestrator.get(parentConfig.value.entity)
153
+ }
154
+
155
+ /**
156
+ * Load entire parent chain recursively
157
+ * Sets breadcrumb entities at correct levels (1 = top ancestor, N = immediate parent)
158
+ */
159
+ async function loadParentChain() {
160
+ if (!parentConfig.value || !parentId.value) return
161
+
162
+ parentLoading.value = true
163
+ const newChain = new Map()
164
+
165
+ try {
166
+ // Calculate total depth to set correct breadcrumb levels
167
+ // If chain is: grandparent -> parent -> current
168
+ // grandparent = level 1, parent = level 2, current = level 3
169
+ const totalDepth = getChainDepth()
170
+
171
+ // Load chain from immediate parent up to root
172
+ let currentConfig = parentConfig.value
173
+ let level = 1 // Level in our chain (1 = immediate parent)
174
+
175
+ while (currentConfig) {
176
+ const parentEntityId = route.params[currentConfig.param]
177
+ if (!parentEntityId) break
178
+
179
+ const parentManager = orchestrator.get(currentConfig.entity)
180
+ if (!parentManager) {
181
+ console.warn(`[useEntityItemPage] Parent manager not found: ${currentConfig.entity}`)
182
+ break
183
+ }
184
+
185
+ const data = await parentManager.get(parentEntityId)
186
+ if (data) {
187
+ newChain.set(level, data)
188
+
189
+ // Set breadcrumb at correct level
190
+ // breadcrumbLevel = totalDepth - level (so immediate parent is totalDepth-1, grandparent is totalDepth-2, etc.)
191
+ // Actually we want: root ancestor = 1, next = 2, ..., immediate parent = totalDepth-1, current = totalDepth
192
+ // So breadcrumbLevel = totalDepth - level
193
+ if (breadcrumb) {
194
+ const breadcrumbLevel = totalDepth - level
195
+ setBreadcrumbEntity(data, breadcrumbLevel)
196
+ }
197
+ }
198
+
199
+ currentConfig = currentConfig.parent
200
+ level++
201
+ }
202
+
203
+ parentChain.value = newChain
204
+ } catch (err) {
205
+ console.warn('[useEntityItemPage] Failed to load parent chain:', err)
206
+ } finally {
207
+ parentLoading.value = false
208
+ }
209
+ }
210
+
211
+ // Legacy alias for backwards compatibility
212
+ const loadParent = loadParentChain
213
+
214
+ /**
215
+ * Get initial data with parent foreignKey auto-populated
216
+ * Useful for form pages creating child entities
217
+ */
218
+ function getInitialDataWithParent() {
219
+ const baseData = manager.getInitialData()
220
+
221
+ if (!parentConfig.value || !parentId.value) {
222
+ return baseData
223
+ }
224
+
225
+ // Auto-populate foreignKey with parent ID
226
+ return {
227
+ ...baseData,
228
+ [parentConfig.value.foreignKey]: parentId.value
229
+ }
230
+ }
231
+
85
232
  // ============ ID EXTRACTION ============
86
233
 
87
234
  /**
@@ -121,8 +268,10 @@ export function useEntityItemPage(config = {}) {
121
268
  data.value = transformed
122
269
 
123
270
  // Share with navigation context for breadcrumb
271
+ // Level = depth of chain (1 if no parent, 2 if 1 parent, 3 if 2 parents, etc.)
124
272
  if (breadcrumb) {
125
- setBreadcrumbEntity(transformed)
273
+ const level = getChainDepth()
274
+ setBreadcrumbEntity(transformed, level)
126
275
  }
127
276
 
128
277
  if (onLoadSuccess) {
@@ -168,13 +317,17 @@ export function useEntityItemPage(config = {}) {
168
317
 
169
318
  // ============ LIFECYCLE ============
170
319
 
171
- if (loadOnMount) {
172
- onMounted(() => {
173
- if (entityId.value) {
174
- load()
175
- }
176
- })
177
- }
320
+ onMounted(async () => {
321
+ // Auto-load parent entity if configured
322
+ if (autoLoadParent && parentConfig.value && parentId.value) {
323
+ await loadParent()
324
+ }
325
+
326
+ // Load current entity
327
+ if (loadOnMount && entityId.value) {
328
+ load()
329
+ }
330
+ })
178
331
 
179
332
  // ============ RETURN ============
180
333
 
@@ -189,6 +342,18 @@ export function useEntityItemPage(config = {}) {
189
342
  entityLabel,
190
343
  isLoaded,
191
344
 
345
+ // Parent chain (supports N-level nesting)
346
+ parentConfig,
347
+ parentId,
348
+ parentData, // Immediate parent (level 1)
349
+ parentChain, // All parents: Map(level -> data)
350
+ parentLoading,
351
+ loadParent, // Alias for loadParentChain
352
+ loadParentChain,
353
+ getParentManager,
354
+ getChainDepth,
355
+ getInitialDataWithParent,
356
+
192
357
  // Actions
193
358
  load,
194
359
  reload,
@@ -321,13 +321,37 @@ export function useListPageBuilder(config = {}) {
321
321
  /**
322
322
  * Add standard "Create" header action
323
323
  * Respects manager.canCreate() for visibility
324
+ *
325
+ * @param {string|object} labelOrOptions - Label string or options object
326
+ * @param {string} labelOrOptions.label - Button label
327
+ * @param {string} labelOrOptions.routeName - Custom route name (overrides auto-detection)
328
+ * @param {object} labelOrOptions.params - Custom route params (merged with current params)
329
+ *
330
+ * @example
331
+ * // Simple usage
332
+ * list.addCreateAction('New Book')
333
+ *
334
+ * // With custom route (for child entities or special cases)
335
+ * list.addCreateAction({ label: 'New Command', routeName: 'bot-command-create' })
324
336
  */
325
- function addCreateAction(label = null) {
326
- const createLabel = label || `Create ${entityName.charAt(0).toUpperCase() + entityName.slice(1)}`
337
+ function addCreateAction(labelOrOptions = null) {
338
+ const options = typeof labelOrOptions === 'string'
339
+ ? { label: labelOrOptions }
340
+ : (labelOrOptions || {})
341
+
342
+ const createLabel = options.label || `Create ${entityName.charAt(0).toUpperCase() + entityName.slice(1)}`
343
+
344
+ const onClick = options.routeName
345
+ ? () => router.push({
346
+ name: options.routeName,
347
+ params: { ...route.params, ...options.params }
348
+ })
349
+ : goToCreate
350
+
327
351
  addHeaderAction('create', {
328
352
  label: createLabel,
329
353
  icon: 'pi pi-plus',
330
- onClick: goToCreate,
354
+ onClick,
331
355
  visible: () => manager.canCreate()
332
356
  })
333
357
  }
@@ -1128,8 +1152,45 @@ export function useListPageBuilder(config = {}) {
1128
1152
  }
1129
1153
 
1130
1154
  // ============ NAVIGATION ============
1155
+
1156
+ /**
1157
+ * Find create route for current context
1158
+ * For child routes (with parent config), looks for sibling create route
1159
+ * Falls back to standard {routePrefix}-create
1160
+ */
1161
+ function findCreateRoute() {
1162
+ const parentConfig = route.meta?.parent
1163
+
1164
+ if (parentConfig) {
1165
+ // Child route: find create route by path pattern
1166
+ // Current matched route path: /bots/:id/commands → look for /bots/:id/commands/create
1167
+ const currentMatched = route.matched[route.matched.length - 1]
1168
+ if (currentMatched) {
1169
+ const createPath = `${currentMatched.path}/create`
1170
+ const createRoute = router.getRoutes().find(r => r.path === createPath)
1171
+ if (createRoute?.name) {
1172
+ return { name: createRoute.name, params: route.params }
1173
+ }
1174
+ }
1175
+
1176
+ // Fallback: try common naming pattern (list-name → singular-create)
1177
+ // e.g., bot-commands → bot-command-create
1178
+ const currentName = route.name
1179
+ if (currentName && currentName.endsWith('s')) {
1180
+ const singularName = currentName.slice(0, -1)
1181
+ const createRouteName = `${singularName}-create`
1182
+ if (router.hasRoute(createRouteName)) {
1183
+ return { name: createRouteName, params: route.params }
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ // Default: standard entity route
1189
+ return { name: `${routePrefix}-create` }
1190
+ }
1191
+
1131
1192
  function goToCreate() {
1132
- router.push({ name: `${routePrefix}-create` })
1193
+ router.push(findCreateRoute())
1133
1194
  }
1134
1195
 
1135
1196
  function goToEdit(item) {