qdadm 0.47.0 → 0.49.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.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -39,7 +39,8 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "dependencies": {
42
- "@quazardous/quarkernel": "^2.1.0"
42
+ "@quazardous/quarkernel": "^2.1.0",
43
+ "pluralize": "^8.0.0"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "pinia": "^2.0.0",
@@ -7,7 +7,7 @@
7
7
  * <InputText v-model="form.username" />
8
8
  * </FormField>
9
9
  *
10
- * The parent form (useFormPageBuilder) provides:
10
+ * The parent form (useEntityItemFormPage) provides:
11
11
  * - isFieldDirty: function to check if field is dirty
12
12
  * - getFieldError: function to get field error message
13
13
  * - handleFieldBlur: function to trigger validation on blur
@@ -44,7 +44,7 @@ const props = defineProps({
44
44
  }
45
45
  })
46
46
 
47
- // Inject from parent form (provided by useFormPageBuilder)
47
+ // Inject from parent form (provided by useEntityItemFormPage)
48
48
  const isFieldDirty = inject('isFieldDirty', () => false)
49
49
  const getFieldError = inject('getFieldError', () => null)
50
50
  const handleFieldBlur = inject('handleFieldBlur', () => {})
@@ -9,10 +9,10 @@
9
9
  * - FormActions footer
10
10
  * - UnsavedChangesDialog integration
11
11
  *
12
- * Props come from useFormPageBuilder composable:
12
+ * Props come from useEntityItemFormPage composable:
13
13
  *
14
14
  * ```vue
15
- * const form = useFormPageBuilder({ entity: 'books' })
15
+ * const form = useEntityItemFormPage({ entity: 'books' })
16
16
  * form.generateFields()
17
17
  * form.addSaveAction()
18
18
  *
@@ -17,9 +17,9 @@
17
17
  * - entity: Current entity data (for dynamic labels in breadcrumb)
18
18
  * - parentEntity: Parent entity data (for parent label in breadcrumb)
19
19
  */
20
- import { computed, ref, watch, onMounted, onUnmounted, inject } from 'vue'
20
+ import { computed, ref, watch, inject } from 'vue'
21
21
  import { useRoute, useRouter } from 'vue-router'
22
- import { getSiblingRoutes } from '../../module/moduleRegistry.js'
22
+ import { getSiblingRoutes, getChildRoutes } from '../../module/moduleRegistry.js'
23
23
  import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
24
24
 
25
25
  // Inject override refs from AppLayout
@@ -110,7 +110,8 @@ const breadcrumbItems = computed(() => {
110
110
  const parentLabel = parentData.value
111
111
  ? parentManager.getEntityLabel(parentData.value)
112
112
  : '...'
113
- const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
113
+ const defaultSuffix = parentManager.readOnly ? '-show' : '-edit'
114
+ const parentRouteName = itemRoute || `${parentManager.routePrefix}${defaultSuffix}`
114
115
 
115
116
  items.push({
116
117
  label: parentLabel,
@@ -128,7 +129,7 @@ const breadcrumbItems = computed(() => {
128
129
  })
129
130
 
130
131
  // Sibling navlinks (routes with same parent)
131
- const navlinks = computed(() => {
132
+ const siblingNavlinks = computed(() => {
132
133
  if (!parentConfig.value) return []
133
134
 
134
135
  const { entity: parentEntityName, param } = parentConfig.value
@@ -147,51 +148,91 @@ const navlinks = computed(() => {
147
148
  })
148
149
  })
149
150
 
150
- // Also include parent "Details" link
151
- const allNavlinks = computed(() => {
152
- if (!parentConfig.value) return []
151
+ // Child navlinks (when on parent detail page, show links to children)
152
+ const childNavlinks = computed(() => {
153
+ // Only when NOT on a child route (no parentConfig)
154
+ if (parentConfig.value) return []
153
155
 
154
- const { entity: parentEntityName, param, itemRoute } = parentConfig.value
155
- const parentId = route.params[param]
156
- const parentManager = getManager(parentEntityName)
156
+ const entityName = route.meta?.entity
157
+ if (!entityName) return []
158
+
159
+ const children = getChildRoutes(entityName)
160
+ if (children.length === 0) return []
161
+
162
+ const entityId = route.params.id
163
+
164
+ // Build navlinks to child routes
165
+ return children.map(childRoute => {
166
+ const childManager = childRoute.meta?.entity ? getManager(childRoute.meta.entity) : null
167
+ const label = childRoute.meta?.navLabel || childManager?.labelPlural || childRoute.name
168
+ const parentParam = childRoute.meta?.parent?.param || 'id'
169
+
170
+ return {
171
+ label,
172
+ to: { name: childRoute.name, params: { [parentParam]: entityId } },
173
+ active: false
174
+ }
175
+ })
176
+ })
177
+
178
+ // Combined navlinks with "Details" link
179
+ const allNavlinks = computed(() => {
180
+ // Case 1: On a child route - show siblings + Details link to parent
181
+ if (parentConfig.value) {
182
+ const { entity: parentEntityName, param, itemRoute } = parentConfig.value
183
+ const parentId = route.params[param]
184
+ const parentManager = getManager(parentEntityName)
185
+
186
+ if (!parentManager) return siblingNavlinks.value
187
+
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
199
+ }
157
200
 
158
- if (!parentManager) return navlinks.value
201
+ return [detailsLink, ...siblingNavlinks.value]
202
+ }
159
203
 
160
- const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
161
- const isOnParentRoute = route.name === parentRouteName
204
+ // Case 2: On parent detail page - show children + Details (active)
205
+ if (childNavlinks.value.length > 0) {
206
+ const detailsLink = {
207
+ label: 'Details',
208
+ to: { name: route.name, params: route.params },
209
+ active: true
210
+ }
162
211
 
163
- // Details link to parent edit form
164
- const detailsLink = {
165
- label: 'Details',
166
- to: { name: parentRouteName, params: { id: parentId } },
167
- active: isOnParentRoute
212
+ return [detailsLink, ...childNavlinks.value]
168
213
  }
169
214
 
170
- return [detailsLink, ...navlinks.value]
215
+ return []
171
216
  })
172
217
 
173
218
  // Sync breadcrumb and navlinks to AppLayout via provide/inject
174
- watch(breadcrumbItems, (items) => {
219
+ // Watch computed values + route changes to ensure updates after navigation
220
+ watch([breadcrumbItems, () => route.fullPath], ([items]) => {
175
221
  if (breadcrumbOverride) {
176
222
  breadcrumbOverride.value = items
177
223
  }
178
224
  }, { immediate: true })
179
225
 
180
- watch(allNavlinks, (links) => {
226
+ watch([allNavlinks, () => route.fullPath], ([links]) => {
181
227
  if (navlinksOverride) {
182
228
  navlinksOverride.value = links
183
229
  }
184
230
  }, { immediate: true })
185
231
 
186
- // Clear overrides when component unmounts (so other pages get default breadcrumb)
187
- onUnmounted(() => {
188
- if (breadcrumbOverride) {
189
- breadcrumbOverride.value = null
190
- }
191
- if (navlinksOverride) {
192
- navlinksOverride.value = null
193
- }
194
- })
232
+ // Note: We intentionally do NOT clear overrides in onUnmounted.
233
+ // When navigating between routes, the new PageNav's watch will overwrite the values.
234
+ // Clearing in onUnmounted causes a race condition where the old PageNav clears
235
+ // AFTER the new PageNav has already set its values.
195
236
  </script>
196
237
 
197
238
  <template>
@@ -7,13 +7,14 @@ export { useBreadcrumb } from './useBreadcrumb'
7
7
  export { useSemanticBreadcrumb, computeSemanticBreadcrumb } from './useSemanticBreadcrumb'
8
8
  export { useDirtyState } from './useDirtyState'
9
9
  export { useForm } from './useForm'
10
- export { useFormPageBuilder } from './useFormPageBuilder'
10
+ export { useEntityItemFormPage, useEntityItemFormPage as useFormPageBuilder } from './useEntityItemFormPage'
11
11
  export * from './useJsonSyntax'
12
12
  export { useListPageBuilder, PAGE_SIZE_OPTIONS } from './useListPageBuilder'
13
13
  export { usePageTitle } from './usePageTitle'
14
14
  export { useApp } from './useApp'
15
15
  export { useAuth } from './useAuth'
16
16
  export { useCurrentEntity } from './useCurrentEntity'
17
+ export { useEntityItemPage } from './useEntityItemPage'
17
18
  export { useNavContext } from './useNavContext'
18
19
  export { useNavigation } from './useNavigation'
19
20
  export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * useFormPageBuilder - Unified procedural builder for CRUD form pages
2
+ * useEntityItemFormPage - Unified procedural builder for CRUD form pages
3
3
  *
4
4
  * Provides a declarative/procedural API to build form pages with:
5
5
  * - Mode detection (create vs edit from route params)
@@ -13,7 +13,7 @@
13
13
  * ## Basic Usage
14
14
  *
15
15
  * ```js
16
- * const form = useFormPageBuilder({ entity: 'books' })
16
+ * const form = useEntityItemFormPage({ entity: 'books' })
17
17
  * form.addSaveAction()
18
18
  * form.addDeleteAction()
19
19
  *
@@ -27,7 +27,7 @@
27
27
  * ## Auto-generated Fields
28
28
  *
29
29
  * ```js
30
- * const form = useFormPageBuilder({ entity: 'books' })
30
+ * const form = useEntityItemFormPage({ entity: 'books' })
31
31
  * form.generateFields() // Auto-generate from manager.fields
32
32
  * form.excludeField('internal_id') // Exclude specific fields
33
33
  * form.addField('custom', { type: 'text', label: 'Custom' }) // Manual override
@@ -40,7 +40,7 @@
40
40
  * ## Validation
41
41
  *
42
42
  * ```js
43
- * const form = useFormPageBuilder({ entity: 'books' })
43
+ * const form = useEntityItemFormPage({ entity: 'books' })
44
44
  * form.generateFields()
45
45
  *
46
46
  * // Add custom validator
@@ -77,19 +77,18 @@
77
77
  * - Unsaved changes guard modal
78
78
  * - Permission-aware save/delete actions via EntityManager.canUpdate/canDelete
79
79
  */
80
- import { ref, computed, watch, onMounted, inject, provide } from 'vue'
80
+ import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
81
81
  import { useRouter, useRoute } from 'vue-router'
82
82
  import { useToast } from 'primevue/usetoast'
83
83
  import { useConfirm } from 'primevue/useconfirm'
84
84
  import { useDirtyState } from './useDirtyState'
85
85
  import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
86
86
  import { useBreadcrumb } from './useBreadcrumb'
87
- import { useCurrentEntity } from './useCurrentEntity'
87
+ import { useEntityItemPage } from './useEntityItemPage'
88
88
  import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
89
89
  import { deepClone } from '../utils/transformers'
90
- import { onUnmounted } from 'vue'
91
90
 
92
- export function useFormPageBuilder(config = {}) {
91
+ export function useEntityItemFormPage(config = {}) {
93
92
  const {
94
93
  entity,
95
94
  // Mode detection
@@ -118,41 +117,22 @@ export function useFormPageBuilder(config = {}) {
118
117
  const toast = useToast()
119
118
  const confirm = useConfirm()
120
119
 
121
- // Get EntityManager via orchestrator
122
- const orchestrator = inject('qdadmOrchestrator')
123
- if (!orchestrator) {
124
- throw new Error(
125
- '[qdadm] Orchestrator not provided.\n' +
126
- 'Possible causes:\n' +
127
- '1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
128
- '2. Component used outside of qdadm app context\n' +
129
- '3. Missing entityFactory in Kernel options'
130
- )
131
- }
132
- const manager = orchestrator.get(entity)
133
-
134
- // Provide entity context for child components (e.g., SeverityTag auto-discovery)
135
- provide('mainEntity', entity)
120
+ // Use useEntityItemPage for common infrastructure
121
+ // (orchestrator, manager, entityId, provide('mainEntity'), breadcrumb)
122
+ const itemPage = useEntityItemPage({
123
+ entity,
124
+ loadOnMount: false, // Form controls its own loading
125
+ breadcrumb: false, // Form calls setBreadcrumbEntity manually after transform
126
+ getId
127
+ })
136
128
 
137
- // Share entity data with navigation context (for breadcrumb)
138
- const { setCurrentEntity } = useCurrentEntity()
129
+ const { manager, orchestrator, entityId, setBreadcrumbEntity } = itemPage
139
130
 
140
131
  // Read config from manager with option overrides
141
132
  const entityName = config.entityName ?? manager.label
142
133
  const routePrefix = config.routePrefix ?? manager.routePrefix
143
134
  const initialData = config.initialData ?? manager.getInitialData()
144
135
 
145
- // ============ MODE DETECTION ============
146
-
147
- /**
148
- * Extract entity ID from route params
149
- * Supports: /books/:id/edit, /books/:id, /books/new
150
- */
151
- const entityId = computed(() => {
152
- if (getId) return getId()
153
- return route.params.id || route.params.key || null
154
- })
155
-
156
136
  /**
157
137
  * Detect form mode: 'create' or 'edit'
158
138
  * Based on route name or presence of ID
@@ -263,7 +243,7 @@ export function useFormPageBuilder(config = {}) {
263
243
  takeSnapshot()
264
244
 
265
245
  // Share with navigation context for breadcrumb
266
- setCurrentEntity(transformed)
246
+ setBreadcrumbEntity(transformed)
267
247
 
268
248
  if (onLoadSuccess) {
269
249
  await onLoadSuccess(transformed)
@@ -520,7 +500,7 @@ export function useFormPageBuilder(config = {}) {
520
500
  const updatedConfig = { ...config, options }
521
501
  fieldsMap.value.set(name, updatedConfig)
522
502
  } catch (error) {
523
- console.warn(`[useFormPageBuilder] Failed to resolve options for field '${name}':`, error)
503
+ console.warn(`[useEntityItemFormPage] Failed to resolve options for field '${name}':`, error)
524
504
  }
525
505
  }
526
506
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * useEntityItemPage - Base composable for single entity item pages
3
+ *
4
+ * Provides common functionality for pages that display a single entity:
5
+ * - Entity loading by ID from route params
6
+ * - Loading/error state management
7
+ * - Breadcrumb integration (auto-calls setBreadcrumbEntity)
8
+ * - Manager access for child composables
9
+ *
10
+ * Used as base for:
11
+ * - Show pages (read-only detail pages)
12
+ * - useEntityItemFormPage (create/edit forms)
13
+ *
14
+ * ## Basic Usage (standalone)
15
+ *
16
+ * ```js
17
+ * const { data, loading, error, reload } = useEntityItemPage({ entity: 'posts' })
18
+ *
19
+ * // data.value contains the loaded entity
20
+ * // loading.value is true while fetching
21
+ * // error.value contains error message if failed
22
+ * ```
23
+ *
24
+ * ## Usage in parent composables
25
+ *
26
+ * ```js
27
+ * function useEntityItemFormPage(config) {
28
+ * const itemPage = useEntityItemPage({
29
+ * entity: config.entity,
30
+ * loadOnMount: false, // Form controls its own loading
31
+ * transformLoad: config.transformLoad
32
+ * })
33
+ *
34
+ * // Use itemPage.load(), itemPage.data, etc.
35
+ * }
36
+ * ```
37
+ */
38
+ import { ref, computed, onMounted, inject, provide } from 'vue'
39
+ import { useRoute } from 'vue-router'
40
+ import { useCurrentEntity } from './useCurrentEntity.js'
41
+
42
+ export function useEntityItemPage(config = {}) {
43
+ const {
44
+ entity,
45
+ // Loading options
46
+ loadOnMount = true,
47
+ breadcrumb = true,
48
+ // ID extraction
49
+ getId = null,
50
+ idParam = 'id',
51
+ // Transform hook
52
+ transformLoad = (data) => data,
53
+ // Callbacks
54
+ onLoadSuccess = null,
55
+ onLoadError = null
56
+ } = config
57
+
58
+ const route = useRoute()
59
+
60
+ // Get EntityManager via orchestrator
61
+ const orchestrator = inject('qdadmOrchestrator')
62
+ if (!orchestrator) {
63
+ throw new Error(
64
+ '[qdadm] Orchestrator not provided.\n' +
65
+ 'Possible causes:\n' +
66
+ '1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
67
+ '2. Component used outside of qdadm app context\n' +
68
+ '3. Missing entityFactory in Kernel options'
69
+ )
70
+ }
71
+ const manager = orchestrator.get(entity)
72
+
73
+ // Provide entity context for child components
74
+ provide('mainEntity', entity)
75
+
76
+ // Breadcrumb integration
77
+ const { setBreadcrumbEntity } = useCurrentEntity()
78
+
79
+ // ============ STATE ============
80
+
81
+ const data = ref(null)
82
+ const loading = ref(false)
83
+ const error = ref(null)
84
+
85
+ // ============ ID EXTRACTION ============
86
+
87
+ /**
88
+ * Extract entity ID from route params
89
+ * Supports custom getId function or param name
90
+ */
91
+ const entityId = computed(() => {
92
+ if (getId) return getId()
93
+ return route.params[idParam] || route.params.id || route.params.key || null
94
+ })
95
+
96
+ // ============ LOADING ============
97
+
98
+ /**
99
+ * Load entity by ID
100
+ * @param {string|number} [id] - Optional ID override (defaults to route param)
101
+ * @returns {Promise<object|null>} Loaded entity data or null on error
102
+ */
103
+ async function load(id = entityId.value) {
104
+ if (!id) {
105
+ error.value = 'No entity ID provided'
106
+ return null
107
+ }
108
+
109
+ loading.value = true
110
+ error.value = null
111
+
112
+ try {
113
+ const responseData = await manager.get(id)
114
+
115
+ if (!responseData) {
116
+ error.value = `${manager.label || entity} not found`
117
+ return null
118
+ }
119
+
120
+ const transformed = transformLoad(responseData)
121
+ data.value = transformed
122
+
123
+ // Share with navigation context for breadcrumb
124
+ if (breadcrumb) {
125
+ setBreadcrumbEntity(transformed)
126
+ }
127
+
128
+ if (onLoadSuccess) {
129
+ await onLoadSuccess(transformed)
130
+ }
131
+
132
+ return transformed
133
+ } catch (err) {
134
+ console.error(`[useEntityItemPage] Failed to load ${entity}:`, err)
135
+ error.value = err.response?.data?.detail || err.message || `Failed to load ${manager.label || entity}`
136
+
137
+ if (onLoadError) {
138
+ await onLoadError(err)
139
+ }
140
+
141
+ return null
142
+ } finally {
143
+ loading.value = false
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Reload current entity
149
+ */
150
+ async function reload() {
151
+ return load(entityId.value)
152
+ }
153
+
154
+ // ============ COMPUTED ============
155
+
156
+ /**
157
+ * Entity label (uses manager.getEntityLabel)
158
+ */
159
+ const entityLabel = computed(() => {
160
+ if (!data.value) return null
161
+ return manager.getEntityLabel(data.value)
162
+ })
163
+
164
+ /**
165
+ * Check if entity is loaded
166
+ */
167
+ const isLoaded = computed(() => data.value !== null)
168
+
169
+ // ============ LIFECYCLE ============
170
+
171
+ if (loadOnMount) {
172
+ onMounted(() => {
173
+ if (entityId.value) {
174
+ load()
175
+ }
176
+ })
177
+ }
178
+
179
+ // ============ RETURN ============
180
+
181
+ return {
182
+ // State
183
+ data,
184
+ loading,
185
+ error,
186
+
187
+ // Computed
188
+ entityId,
189
+ entityLabel,
190
+ isLoaded,
191
+
192
+ // Actions
193
+ load,
194
+ reload,
195
+
196
+ // References (for parent composables)
197
+ manager,
198
+ orchestrator,
199
+ setBreadcrumbEntity
200
+ }
201
+ }
@@ -46,6 +46,19 @@ export function useNavContext(options = {}) {
46
46
  return router.getRoutes().some(r => r.name === name)
47
47
  }
48
48
 
49
+ /**
50
+ * Get default item route for an entity manager
51
+ * - Read-only entities: use -show suffix
52
+ * - Editable entities: use -edit suffix
53
+ * @param {EntityManager} manager
54
+ * @returns {string} Route name
55
+ */
56
+ function getDefaultItemRoute(manager) {
57
+ if (!manager) return null
58
+ const suffix = manager.readOnly ? '-show' : '-edit'
59
+ return `${manager.routePrefix}${suffix}`
60
+ }
61
+
49
62
  /**
50
63
  * Convert semantic breadcrumb to navigation chain format
51
64
  * Maps semantic kinds to chain types for compatibility
@@ -71,7 +84,7 @@ export function useNavContext(options = {}) {
71
84
  entity: item.entity,
72
85
  manager,
73
86
  id: item.id,
74
- routeName: manager ? `${manager.routePrefix}-edit` : null
87
+ routeName: getDefaultItemRoute(manager)
75
88
  })
76
89
  } else if (item.kind === 'entity-create') {
77
90
  // Create page - treat as special list
@@ -246,7 +259,7 @@ export function useNavContext(options = {}) {
246
259
  const parentManager = getManager(parentEntity)
247
260
  if (!parentManager) return []
248
261
 
249
- const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
262
+ const parentRouteName = itemRoute || getDefaultItemRoute(parentManager)
250
263
  const isOnParent = route.name === parentRouteName
251
264
 
252
265
  // Details link
@@ -1,6 +1,7 @@
1
1
  import { PermissiveAuthAdapter } from './auth/PermissiveAdapter.js'
2
2
  import { AuthActions } from './auth/EntityAuthAdapter.js'
3
3
  import { QueryExecutor } from '../query/QueryExecutor.js'
4
+ import pluralize from 'pluralize'
4
5
 
5
6
  /**
6
7
  * EntityManager - Base class for entity CRUD operations
@@ -307,36 +308,34 @@ export class EntityManager {
307
308
 
308
309
  /**
309
310
  * Get entity label (singular)
310
- * Default: capitalize name (e.g., 'users' → 'User')
311
+ * Default: capitalize name (e.g., 'users' → 'User', 'countries' → 'Country')
311
312
  */
312
313
  get label() {
313
314
  if (this._label) return this._label
314
315
  if (!this.name) return 'Item'
315
- // users User, book → Book
316
- const singular = this.name.endsWith('s') ? this.name.slice(0, -1) : this.name
316
+ const singular = pluralize.singular(this.name)
317
317
  return singular.charAt(0).toUpperCase() + singular.slice(1)
318
318
  }
319
319
 
320
320
  /**
321
321
  * Get entity label (plural)
322
- * Default: capitalize name or add 's' (e.g., 'user' → 'Users')
322
+ * Default: capitalize name (e.g., 'user' → 'Users', 'country' → 'Countries')
323
323
  */
324
324
  get labelPlural() {
325
325
  if (this._labelPlural) return this._labelPlural
326
326
  if (!this.name) return 'Items'
327
- // users Users, book → Books
328
- const plural = this.name.endsWith('s') ? this.name : this.name + 's'
327
+ const plural = pluralize.plural(this.name)
329
328
  return plural.charAt(0).toUpperCase() + plural.slice(1)
330
329
  }
331
330
 
332
331
  /**
333
332
  * Get route prefix for this entity
334
- * Default: singular form of name (e.g., 'books' → 'book')
333
+ * Default: singular form of name (e.g., 'books' → 'book', 'countries' → 'country')
335
334
  */
336
335
  get routePrefix() {
337
336
  if (this._routePrefix) return this._routePrefix
338
337
  if (!this.name) return 'item'
339
- return this.name.endsWith('s') ? this.name.slice(0, -1) : this.name
338
+ return pluralize.singular(this.name)
340
339
  }
341
340
 
342
341
  /**
@@ -49,13 +49,21 @@ let alterationPromise = null
49
49
  const registry = {
50
50
  /**
51
51
  * Add routes for this module
52
- * @param {string} prefix - Path prefix for all routes (e.g., 'agents')
52
+ *
53
+ * CONVENTION: Entity item routes MUST use :id as param name
54
+ * - List route: 'books' → /books
55
+ * - Item route: 'books/:id' → /books/:id (MUST be :id, not :bookId or :uuid)
56
+ * - Child route: 'books/:id/reviews' → parent.param = 'id'
57
+ *
58
+ * This convention is required for PageNav, breadcrumbs, and navigation to work correctly.
59
+ *
60
+ * @param {string} prefix - Path prefix for all routes (e.g., 'books' or 'books/:id/reviews')
53
61
  * @param {Array} moduleRoutes - Route definitions with relative paths
54
62
  * @param {object} options - Route options
55
63
  * @param {string} [options.entity] - Entity name for permission checking
56
64
  * @param {object} [options.parent] - Parent entity config for child routes
57
65
  * @param {string} options.parent.entity - Parent entity name (e.g., 'books')
58
- * @param {string} options.parent.param - Route param for parent ID (e.g., 'bookId')
66
+ * @param {string} options.parent.param - Route param for parent ID (MUST be 'id')
59
67
  * @param {string} options.parent.foreignKey - Foreign key field (e.g., 'book_id')
60
68
  * @param {string} [options.parent.itemRoute] - Override parent item route (auto: parentEntity.routePrefix + '-edit')
61
69
  * @param {string} [options.label] - Label for navlinks (defaults to entity labelPlural)
@@ -341,6 +349,18 @@ export function getSiblingRoutes(parentEntity, parentParam) {
341
349
  })
342
350
  }
343
351
 
352
+ /**
353
+ * Get child routes (routes that have this entity as parent)
354
+ * @param {string} entityName - Entity name to find children for
355
+ * @returns {Array} Routes with this entity as parent
356
+ */
357
+ export function getChildRoutes(entityName) {
358
+ return routes.filter(route => {
359
+ const parent = route.meta?.parent
360
+ return parent?.entity === entityName
361
+ })
362
+ }
363
+
344
364
  /**
345
365
  * Check if a route belongs to a family
346
366
  */
@@ -4,13 +4,13 @@
4
4
  */
5
5
 
6
6
  import { ref, computed, inject } from 'vue'
7
- import { useFormPageBuilder, FormPage, useOrchestrator, PermissionEditor } from '../../index.js'
7
+ import { useEntityItemFormPage, FormPage, useOrchestrator, PermissionEditor } from '../../index.js'
8
8
  import InputText from 'primevue/inputtext'
9
9
  import AutoComplete from 'primevue/autocomplete'
10
10
  import Chip from 'primevue/chip'
11
11
 
12
12
  // ============ FORM BUILDER ============
13
- const form = useFormPageBuilder({ entity: 'roles' })
13
+ const form = useEntityItemFormPage({ entity: 'roles' })
14
14
 
15
15
  // ============ HELPERS ============
16
16
  const { getManager } = useOrchestrator()