qdadm 0.49.1 → 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.1",
3
+ "version": "0.50.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -31,7 +31,9 @@ const breadcrumbEntities = inject('qdadmBreadcrumbEntities', null)
31
31
 
32
32
  const props = defineProps({
33
33
  entity: { type: Object, default: null },
34
- 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 }
35
37
  })
36
38
 
37
39
  const route = useRoute()
@@ -121,8 +123,10 @@ const breadcrumbItems = computed(() => {
121
123
  })
122
124
 
123
125
  // Parent item (with label from data)
124
- const parentLabel = parentData.value
125
- ? 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)
126
130
  : '...'
127
131
  const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
128
132
  const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
@@ -149,8 +153,16 @@ const siblingNavlinks = computed(() => {
149
153
  const { entity: parentEntityName, param } = parentConfig.value
150
154
  const siblings = getSiblingRoutes(parentEntityName, param)
151
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
+
152
164
  // Build navlinks with current route params
153
- return siblings.map(siblingRoute => {
165
+ return navRoutes.map(siblingRoute => {
154
166
  const manager = siblingRoute.meta?.entity ? getManager(siblingRoute.meta.entity) : null
155
167
  const label = siblingRoute.meta?.navLabel || manager?.labelPlural || siblingRoute.name
156
168
 
@@ -173,10 +185,19 @@ const childNavlinks = computed(() => {
173
185
  const children = getChildRoutes(entityName)
174
186
  if (children.length === 0) return []
175
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
+
176
197
  const entityId = route.params.id
177
198
 
178
199
  // Build navlinks to child routes
179
- return children.map(childRoute => {
200
+ return navRoutes.map(childRoute => {
180
201
  const childManager = childRoute.meta?.entity ? getManager(childRoute.meta.entity) : null
181
202
  const label = childRoute.meta?.navLabel || childManager?.labelPlural || childRoute.name
182
203
  const parentParam = childRoute.meta?.parent?.param || 'id'
@@ -191,7 +212,7 @@ const childNavlinks = computed(() => {
191
212
 
192
213
  // Combined navlinks with "Details" link
193
214
  const allNavlinks = computed(() => {
194
- // 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
195
216
  if (parentConfig.value) {
196
217
  const { entity: parentEntityName, param, itemRoute } = parentConfig.value
197
218
  const parentId = route.params[param]
@@ -199,31 +220,38 @@ const allNavlinks = computed(() => {
199
220
 
200
221
  if (!parentManager) return siblingNavlinks.value
201
222
 
202
- const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
203
- const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
204
- const isOnParentRoute = route.name === parentRouteName
205
-
206
- // Details link to parent item page
207
- // CONVENTION: Entity item routes MUST use :id as param name (e.g., /books/:id)
208
- // This is a qdadm convention - all entity detail/edit routes expect 'id' param
209
- const detailsLink = {
210
- label: 'Details',
211
- to: { name: parentRouteName, params: { id: parentId } },
212
- 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]
213
238
  }
214
239
 
215
- return [detailsLink, ...siblingNavlinks.value]
240
+ return siblingNavlinks.value
216
241
  }
217
242
 
218
- // Case 2: On parent detail page - show children + Details (active)
243
+ // Case 2: On parent detail page - show children + optional Details (active)
219
244
  if (childNavlinks.value.length > 0) {
220
- const detailsLink = {
221
- label: 'Details',
222
- to: { name: route.name, params: route.params },
223
- 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]
224
253
  }
225
-
226
- return [detailsLink, ...childNavlinks.value]
254
+ return childNavlinks.value
227
255
  }
228
256
 
229
257
  return []
@@ -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) {