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 +3 -2
- package/src/components/forms/FormField.vue +2 -2
- package/src/components/forms/FormPage.vue +2 -2
- package/src/components/layout/PageNav.vue +71 -30
- package/src/composables/index.js +2 -1
- package/src/composables/{useFormPageBuilder.js → useEntityItemFormPage.js} +18 -38
- package/src/composables/useEntityItemPage.js +201 -0
- package/src/composables/useNavContext.js +15 -2
- package/src/entity/EntityManager.js +7 -8
- package/src/module/moduleRegistry.js +22 -2
- package/src/security/pages/RoleForm.vue +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "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 (
|
|
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
|
|
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
|
|
12
|
+
* Props come from useEntityItemFormPage composable:
|
|
13
13
|
*
|
|
14
14
|
* ```vue
|
|
15
|
-
* const form =
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
201
|
+
return [detailsLink, ...siblingNavlinks.value]
|
|
202
|
+
}
|
|
159
203
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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 [
|
|
215
|
+
return []
|
|
171
216
|
})
|
|
172
217
|
|
|
173
218
|
// Sync breadcrumb and navlinks to AppLayout via provide/inject
|
|
174
|
-
|
|
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
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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>
|
package/src/composables/index.js
CHANGED
|
@@ -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 './
|
|
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
|
-
*
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`[
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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 (
|
|
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 {
|
|
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 =
|
|
13
|
+
const form = useEntityItemFormPage({ entity: 'roles' })
|
|
14
14
|
|
|
15
15
|
// ============ HELPERS ============
|
|
16
16
|
const { getManager } = useOrchestrator()
|