qdadm 0.16.0 → 0.18.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/README.md +153 -1
- package/package.json +15 -2
- package/src/components/forms/FormField.vue +64 -6
- package/src/components/forms/FormPage.vue +276 -0
- package/src/components/index.js +11 -0
- package/src/components/layout/BaseLayout.vue +183 -0
- package/src/components/layout/DashboardLayout.vue +100 -0
- package/src/components/layout/FormLayout.vue +261 -0
- package/src/components/layout/ListLayout.vue +334 -0
- package/src/components/layout/Zone.vue +165 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
- package/src/components/layout/defaults/DefaultFooter.vue +56 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
- package/src/components/layout/defaults/DefaultHeader.vue +69 -0
- package/src/components/layout/defaults/DefaultMenu.vue +197 -0
- package/src/components/layout/defaults/DefaultPagination.vue +79 -0
- package/src/components/layout/defaults/DefaultTable.vue +130 -0
- package/src/components/layout/defaults/DefaultToaster.vue +16 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
- package/src/components/layout/defaults/index.js +17 -0
- package/src/composables/index.js +6 -6
- package/src/composables/useForm.js +135 -0
- package/src/composables/useFormPageBuilder.js +1154 -0
- package/src/composables/useHooks.js +53 -0
- package/src/composables/useLayoutResolver.js +260 -0
- package/src/composables/useListPageBuilder.js +336 -52
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/useSignals.js +49 -0
- package/src/composables/useZoneRegistry.js +162 -0
- package/src/core/bundles.js +406 -0
- package/src/core/decorator.js +322 -0
- package/src/core/extension.js +386 -0
- package/src/core/index.js +28 -0
- package/src/entity/EntityManager.js +359 -16
- package/src/entity/auth/AuthAdapter.js +184 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/RoleHierarchy.js +153 -0
- package/src/entity/auth/SecurityChecker.js +167 -0
- package/src/entity/auth/index.js +18 -0
- package/src/entity/index.js +3 -0
- package/src/entity/storage/MockApiStorage.js +349 -0
- package/src/entity/storage/SdkStorage.js +478 -0
- package/src/entity/storage/index.js +2 -0
- package/src/hooks/HookRegistry.js +411 -0
- package/src/hooks/index.js +12 -0
- package/src/index.js +13 -0
- package/src/kernel/Kernel.js +206 -5
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +155 -28
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/zones/ZoneRegistry.js +828 -0
- package/src/zones/index.js +16 -0
- package/src/zones/zones.js +189 -0
- package/src/composables/useEntityTitle.js +0 -121
- package/src/composables/useManager.js +0 -20
- package/src/composables/usePageBuilder.js +0 -334
- package/src/composables/useStatus.js +0 -146
- package/src/composables/useSubEditor.js +0 -165
- package/src/composables/useTabSync.js +0 -110
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ kernel.createApp().mount('#app')
|
|
|
44
44
|
|
|
45
45
|
```js
|
|
46
46
|
// Main
|
|
47
|
-
import { Kernel, createQdadm, EntityManager, ApiStorage, LocalStorage } from 'qdadm'
|
|
47
|
+
import { Kernel, createQdadm, EntityManager, ApiStorage, LocalStorage, SdkStorage } from 'qdadm'
|
|
48
48
|
|
|
49
49
|
// Composables
|
|
50
50
|
import { useForm, useBareForm, useListPageBuilder } from 'qdadm/composables'
|
|
@@ -62,6 +62,158 @@ import { formatDate, truncate } from 'qdadm/utils'
|
|
|
62
62
|
import 'qdadm/styles'
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
## SdkStorage
|
|
66
|
+
|
|
67
|
+
Adapter for generated SDK clients (hey-api, openapi-generator, etc.). Maps SDK methods to standard CRUD operations with optional transforms.
|
|
68
|
+
|
|
69
|
+
### Basic Usage
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { EntityManager, SdkStorage } from 'qdadm'
|
|
73
|
+
import { Sdk } from './generated/sdk.gen.js'
|
|
74
|
+
|
|
75
|
+
const sdk = new Sdk({ client: myClient })
|
|
76
|
+
|
|
77
|
+
const storage = new SdkStorage({
|
|
78
|
+
sdk,
|
|
79
|
+
methods: {
|
|
80
|
+
list: 'getApiAdminTasks',
|
|
81
|
+
get: 'getApiAdminTasksById',
|
|
82
|
+
create: 'postApiAdminTasks',
|
|
83
|
+
update: 'patchApiAdminTasksById',
|
|
84
|
+
delete: 'deleteApiAdminTasksById'
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const manager = new EntityManager({
|
|
89
|
+
name: 'tasks',
|
|
90
|
+
storage,
|
|
91
|
+
labelField: 'name'
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Configuration Options
|
|
96
|
+
|
|
97
|
+
| Option | Type | Description |
|
|
98
|
+
|--------|------|-------------|
|
|
99
|
+
| `sdk` | object | SDK instance |
|
|
100
|
+
| `getSdk` | function | Callback for lazy SDK loading |
|
|
101
|
+
| `methods` | object | Map operations to SDK method names or callbacks |
|
|
102
|
+
| `transformRequest` | function | Global request transform `(operation, params) => params` |
|
|
103
|
+
| `transformResponse` | function | Global response transform `(operation, data) => data` |
|
|
104
|
+
| `transforms` | object | Per-method transforms (override global) |
|
|
105
|
+
| `responseFormat` | object | Configure response normalization |
|
|
106
|
+
| `clientSidePagination` | boolean | Handle pagination locally (default: false) |
|
|
107
|
+
|
|
108
|
+
### Method Mapping
|
|
109
|
+
|
|
110
|
+
Methods can be strings (SDK method name) or callbacks for full control:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
methods: {
|
|
114
|
+
// String: calls sdk.getItems({ query: params })
|
|
115
|
+
list: 'getItems',
|
|
116
|
+
|
|
117
|
+
// Callback: full control over SDK invocation
|
|
118
|
+
get: async (sdk, id) => {
|
|
119
|
+
const result = await sdk.getItemById({ path: { id } })
|
|
120
|
+
return result.data
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Transform Callbacks
|
|
126
|
+
|
|
127
|
+
**Global transforms** apply to all operations:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
new SdkStorage({
|
|
131
|
+
sdk,
|
|
132
|
+
methods: { list: 'getItems', get: 'getItemById' },
|
|
133
|
+
transformRequest: (operation, params) => {
|
|
134
|
+
if (operation === 'list') {
|
|
135
|
+
return { query: params }
|
|
136
|
+
}
|
|
137
|
+
return params
|
|
138
|
+
},
|
|
139
|
+
transformResponse: (operation, response) => response.data
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Per-method transforms** override global ones:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
new SdkStorage({
|
|
147
|
+
sdk,
|
|
148
|
+
methods: { list: 'getItems' },
|
|
149
|
+
transforms: {
|
|
150
|
+
list: {
|
|
151
|
+
request: (params) => ({ query: { ...params, active: true } }),
|
|
152
|
+
response: (data) => ({ items: data.results, total: data.count })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Response Format Normalization
|
|
159
|
+
|
|
160
|
+
For APIs with non-standard response shapes, configure normalization before transforms:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
new SdkStorage({
|
|
164
|
+
sdk,
|
|
165
|
+
methods: { list: 'getItems' },
|
|
166
|
+
responseFormat: {
|
|
167
|
+
dataField: 'results', // Field containing array (e.g., 'data', 'results')
|
|
168
|
+
totalField: 'count', // Field for total count (null = compute from array)
|
|
169
|
+
itemsField: 'data.items' // Nested path (takes precedence over dataField)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Client-Side Pagination
|
|
175
|
+
|
|
176
|
+
For SDKs that return all items without server-side pagination:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
new SdkStorage({
|
|
180
|
+
sdk,
|
|
181
|
+
methods: { list: 'getAllItems' },
|
|
182
|
+
clientSidePagination: true // Fetches all, paginates/sorts/filters in-memory
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Real-World Example (hey-api SDK)
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { Sdk } from '@/generated/sdk.gen.js'
|
|
190
|
+
import { client } from '@/generated/client.gen.js'
|
|
191
|
+
|
|
192
|
+
// Configure client
|
|
193
|
+
client.setConfig({ baseUrl: '/api' })
|
|
194
|
+
|
|
195
|
+
const sdk = new Sdk({ client })
|
|
196
|
+
|
|
197
|
+
const taskStorage = new SdkStorage({
|
|
198
|
+
sdk,
|
|
199
|
+
methods: {
|
|
200
|
+
list: 'getApiAdminTasks',
|
|
201
|
+
get: 'getApiAdminTasksById',
|
|
202
|
+
create: 'postApiAdminTasks',
|
|
203
|
+
patch: 'patchApiAdminTasksById',
|
|
204
|
+
delete: 'deleteApiAdminTasksById'
|
|
205
|
+
},
|
|
206
|
+
transforms: {
|
|
207
|
+
list: {
|
|
208
|
+
response: (data) => ({
|
|
209
|
+
items: data.items.map(t => ({ ...t, statusLabel: t.status.toUpperCase() })),
|
|
210
|
+
total: data.total
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
65
217
|
## Peer Dependencies
|
|
66
218
|
|
|
67
219
|
- vue ^3.3.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
16
|
"main": "src/index.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest"
|
|
20
|
+
},
|
|
17
21
|
"exports": {
|
|
18
22
|
".": "./src/index.js",
|
|
19
23
|
"./composables": "./src/composables/index.js",
|
|
@@ -27,6 +31,9 @@
|
|
|
27
31
|
"README.md",
|
|
28
32
|
"LICENSE"
|
|
29
33
|
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@quazardous/quarkernel": "^2.1.0"
|
|
36
|
+
},
|
|
30
37
|
"peerDependencies": {
|
|
31
38
|
"vue": "^3.3.0",
|
|
32
39
|
"vue-router": "^4.0.0",
|
|
@@ -43,5 +50,11 @@
|
|
|
43
50
|
"primevue",
|
|
44
51
|
"crud",
|
|
45
52
|
"entity-manager"
|
|
46
|
-
]
|
|
53
|
+
],
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
56
|
+
"@vue/test-utils": "^2.4.6",
|
|
57
|
+
"jsdom": "^25.0.1",
|
|
58
|
+
"vitest": "^2.1.8"
|
|
59
|
+
}
|
|
47
60
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
/**
|
|
3
|
-
* FormField - Wrapper for form fields with automatic dirty state
|
|
3
|
+
* FormField - Wrapper for form fields with automatic dirty state and error display
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* <FormField name="username" label="Username *">
|
|
7
7
|
* <InputText v-model="form.username" />
|
|
8
8
|
* </FormField>
|
|
9
9
|
*
|
|
10
|
-
* The parent form
|
|
10
|
+
* The parent form (useFormPageBuilder) provides:
|
|
11
|
+
* - isFieldDirty: function to check if field is dirty
|
|
12
|
+
* - getFieldError: function to get field error message
|
|
13
|
+
* - handleFieldBlur: function to trigger validation on blur
|
|
14
|
+
* - formSubmitted: ref indicating if form was submitted
|
|
11
15
|
*/
|
|
12
16
|
import { inject, computed } from 'vue'
|
|
13
17
|
|
|
@@ -27,31 +31,63 @@ const props = defineProps({
|
|
|
27
31
|
fullWidth: {
|
|
28
32
|
type: Boolean,
|
|
29
33
|
default: false
|
|
34
|
+
},
|
|
35
|
+
/** Override error message (useful for custom validation) */
|
|
36
|
+
error: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: null
|
|
39
|
+
},
|
|
40
|
+
/** Show error only after form submission */
|
|
41
|
+
showErrorOnSubmit: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: false
|
|
30
44
|
}
|
|
31
45
|
})
|
|
32
46
|
|
|
33
|
-
// Inject
|
|
47
|
+
// Inject from parent form (provided by useFormPageBuilder)
|
|
34
48
|
const isFieldDirty = inject('isFieldDirty', () => false)
|
|
49
|
+
const getFieldError = inject('getFieldError', () => null)
|
|
50
|
+
const handleFieldBlur = inject('handleFieldBlur', () => {})
|
|
51
|
+
const formSubmitted = inject('formSubmitted', { value: false })
|
|
35
52
|
|
|
36
53
|
const isDirty = computed(() => isFieldDirty(props.name))
|
|
37
54
|
|
|
55
|
+
// Get error from prop or from form validation
|
|
56
|
+
const fieldError = computed(() => {
|
|
57
|
+
if (props.error) return props.error
|
|
58
|
+
return getFieldError(props.name)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Show error if: form submitted OR field was touched (validated on blur)
|
|
62
|
+
const showError = computed(() => {
|
|
63
|
+
if (!fieldError.value) return false
|
|
64
|
+
if (props.showErrorOnSubmit) return formSubmitted.value
|
|
65
|
+
return true
|
|
66
|
+
})
|
|
67
|
+
|
|
38
68
|
const fieldClasses = computed(() => [
|
|
39
69
|
'form-field',
|
|
40
70
|
{
|
|
41
|
-
'field-dirty': isDirty.value
|
|
71
|
+
'field-dirty': isDirty.value,
|
|
72
|
+
'field-invalid': showError.value
|
|
42
73
|
}
|
|
43
74
|
])
|
|
44
75
|
|
|
45
76
|
const fieldStyle = computed(() =>
|
|
46
77
|
props.fullWidth ? { gridColumn: '1 / -1' } : {}
|
|
47
78
|
)
|
|
79
|
+
|
|
80
|
+
function onBlur() {
|
|
81
|
+
handleFieldBlur(props.name)
|
|
82
|
+
}
|
|
48
83
|
</script>
|
|
49
84
|
|
|
50
85
|
<template>
|
|
51
86
|
<div :class="fieldClasses" :style="fieldStyle">
|
|
52
87
|
<label v-if="label" :for="name">{{ label }}</label>
|
|
53
|
-
<slot ></slot>
|
|
54
|
-
<small v-if="
|
|
88
|
+
<slot :onBlur="onBlur"></slot>
|
|
89
|
+
<small v-if="showError" class="field-error">{{ fieldError }}</small>
|
|
90
|
+
<small v-else-if="hint" class="field-hint">{{ hint }}</small>
|
|
55
91
|
</div>
|
|
56
92
|
</template>
|
|
57
93
|
|
|
@@ -61,4 +97,26 @@ const fieldStyle = computed(() =>
|
|
|
61
97
|
margin-top: 0.25rem;
|
|
62
98
|
display: block;
|
|
63
99
|
}
|
|
100
|
+
|
|
101
|
+
.field-error {
|
|
102
|
+
color: var(--p-red-500);
|
|
103
|
+
margin-top: 0.25rem;
|
|
104
|
+
display: block;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.field-invalid :deep(input),
|
|
108
|
+
.field-invalid :deep(textarea),
|
|
109
|
+
.field-invalid :deep(.p-inputtext),
|
|
110
|
+
.field-invalid :deep(.p-select),
|
|
111
|
+
.field-invalid :deep(.p-dropdown) {
|
|
112
|
+
border-color: var(--p-red-500);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.field-invalid :deep(input:focus),
|
|
116
|
+
.field-invalid :deep(textarea:focus),
|
|
117
|
+
.field-invalid :deep(.p-inputtext:focus),
|
|
118
|
+
.field-invalid :deep(.p-select:focus),
|
|
119
|
+
.field-invalid :deep(.p-dropdown:focus) {
|
|
120
|
+
box-shadow: 0 0 0 1px var(--p-red-500);
|
|
121
|
+
}
|
|
64
122
|
</style>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* FormPage - Unified form page component
|
|
4
|
+
*
|
|
5
|
+
* Renders a complete CRUD form page with:
|
|
6
|
+
* - PageHeader with title and action buttons
|
|
7
|
+
* - Loading and error states
|
|
8
|
+
* - Form content via slots
|
|
9
|
+
* - FormActions footer
|
|
10
|
+
* - UnsavedChangesDialog integration
|
|
11
|
+
*
|
|
12
|
+
* Props come from useFormPageBuilder composable:
|
|
13
|
+
*
|
|
14
|
+
* ```vue
|
|
15
|
+
* const form = useFormPageBuilder({ entity: 'books' })
|
|
16
|
+
* form.generateFields()
|
|
17
|
+
* form.addSaveAction()
|
|
18
|
+
*
|
|
19
|
+
* <FormPage v-bind="form.props" v-on="form.events">
|
|
20
|
+
* <template #fields>
|
|
21
|
+
* <FormField v-model="form.data.title" name="title" />
|
|
22
|
+
* </template>
|
|
23
|
+
* </FormPage>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Slots:
|
|
27
|
+
* - #nav: PageNav for breadcrumb customization
|
|
28
|
+
* - #toolbar: Custom toolbar actions (between header and form)
|
|
29
|
+
* - #header-actions: Custom header action buttons
|
|
30
|
+
* - #fields: Form fields content (required)
|
|
31
|
+
* - #footer: Custom footer (replaces FormActions if provided)
|
|
32
|
+
* - #error: Custom error display
|
|
33
|
+
* - #loading: Custom loading display
|
|
34
|
+
*/
|
|
35
|
+
import { computed } from 'vue'
|
|
36
|
+
import PageHeader from '../layout/PageHeader.vue'
|
|
37
|
+
import FormActions from './FormActions.vue'
|
|
38
|
+
import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
|
|
39
|
+
import Card from 'primevue/card'
|
|
40
|
+
import Button from 'primevue/button'
|
|
41
|
+
import Message from 'primevue/message'
|
|
42
|
+
|
|
43
|
+
const props = defineProps({
|
|
44
|
+
// Mode
|
|
45
|
+
isEdit: { type: Boolean, default: false },
|
|
46
|
+
mode: { type: String, default: 'create' },
|
|
47
|
+
|
|
48
|
+
// State
|
|
49
|
+
loading: { type: Boolean, default: false },
|
|
50
|
+
saving: { type: Boolean, default: false },
|
|
51
|
+
dirty: { type: Boolean, default: false },
|
|
52
|
+
|
|
53
|
+
// Title (use title OR titleParts)
|
|
54
|
+
title: { type: String, default: null },
|
|
55
|
+
titleParts: { type: Object, default: null },
|
|
56
|
+
|
|
57
|
+
// Fields (for auto-rendering - optional, can use #fields slot instead)
|
|
58
|
+
fields: { type: Array, default: () => [] },
|
|
59
|
+
|
|
60
|
+
// Actions (from builder.actions)
|
|
61
|
+
actions: { type: Array, default: () => [] },
|
|
62
|
+
|
|
63
|
+
// Validation state
|
|
64
|
+
errors: { type: Object, default: () => ({}) },
|
|
65
|
+
hasErrors: { type: Boolean, default: false },
|
|
66
|
+
errorSummary: { type: Array, default: null },
|
|
67
|
+
submitted: { type: Boolean, default: false },
|
|
68
|
+
|
|
69
|
+
// Guard dialog (from useUnsavedChangesGuard)
|
|
70
|
+
guardDialog: { type: Object, default: null },
|
|
71
|
+
|
|
72
|
+
// Error for fetch failures (separate from validation errors)
|
|
73
|
+
fetchError: { type: [String, Object], default: null },
|
|
74
|
+
|
|
75
|
+
// UI options
|
|
76
|
+
showFormActions: { type: Boolean, default: true },
|
|
77
|
+
showSaveAndClose: { type: Boolean, default: true },
|
|
78
|
+
cardWrapper: { type: Boolean, default: true }
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const emit = defineEmits(['save', 'saveAndClose', 'cancel', 'delete'])
|
|
82
|
+
|
|
83
|
+
// Computed: has any action buttons to show
|
|
84
|
+
const hasActions = computed(() => props.actions.length > 0)
|
|
85
|
+
|
|
86
|
+
// Get action by name
|
|
87
|
+
function getAction(name) {
|
|
88
|
+
return props.actions.find(a => a.name === name)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if action exists and is visible
|
|
92
|
+
function hasAction(name) {
|
|
93
|
+
return props.actions.some(a => a.name === name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Extract specific actions for FormActions component
|
|
97
|
+
const saveAction = computed(() => getAction('save'))
|
|
98
|
+
const deleteAction = computed(() => getAction('delete'))
|
|
99
|
+
const cancelAction = computed(() => getAction('cancel'))
|
|
100
|
+
|
|
101
|
+
// Header actions: all actions except save, delete, cancel (those go in footer)
|
|
102
|
+
const headerActions = computed(() =>
|
|
103
|
+
props.actions.filter(a => !['save', 'delete', 'cancel'].includes(a.name))
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Get error message from fetchError
|
|
107
|
+
const fetchErrorMessage = computed(() => {
|
|
108
|
+
if (!props.fetchError) return null
|
|
109
|
+
if (typeof props.fetchError === 'string') return props.fetchError
|
|
110
|
+
return props.fetchError.message || props.fetchError.detail || 'Failed to load entity'
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Guard dialog handlers
|
|
114
|
+
function onGuardSaveAndLeave() {
|
|
115
|
+
if (props.guardDialog?.onSave) {
|
|
116
|
+
props.guardDialog.onSave()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onGuardLeave() {
|
|
121
|
+
if (props.guardDialog?.onLeave) {
|
|
122
|
+
props.guardDialog.onLeave()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onGuardStay() {
|
|
127
|
+
if (props.guardDialog?.onStay) {
|
|
128
|
+
props.guardDialog.onStay()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<div class="form-page">
|
|
135
|
+
<!-- Nav slot for PageNav (child routes) -->
|
|
136
|
+
<slot name="nav" />
|
|
137
|
+
|
|
138
|
+
<PageHeader :title="title" :title-parts="titleParts">
|
|
139
|
+
<template #actions>
|
|
140
|
+
<slot name="header-actions" />
|
|
141
|
+
<!-- Header actions from builder (excludes save/delete/cancel) -->
|
|
142
|
+
<Button
|
|
143
|
+
v-for="action in headerActions"
|
|
144
|
+
:key="action.name"
|
|
145
|
+
:label="action.label"
|
|
146
|
+
:icon="action.icon"
|
|
147
|
+
:severity="action.severity"
|
|
148
|
+
:loading="action.isLoading"
|
|
149
|
+
:disabled="action.isDisabled"
|
|
150
|
+
@click="action.onClick"
|
|
151
|
+
/>
|
|
152
|
+
</template>
|
|
153
|
+
</PageHeader>
|
|
154
|
+
|
|
155
|
+
<!-- Toolbar slot (between header and form) -->
|
|
156
|
+
<slot name="toolbar" />
|
|
157
|
+
|
|
158
|
+
<!-- Loading State -->
|
|
159
|
+
<template v-if="loading">
|
|
160
|
+
<slot name="loading">
|
|
161
|
+
<div class="loading-state">
|
|
162
|
+
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
|
|
163
|
+
</div>
|
|
164
|
+
</slot>
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
<!-- Error State (fetch error) -->
|
|
168
|
+
<template v-else-if="fetchError">
|
|
169
|
+
<slot name="error" :error="fetchError">
|
|
170
|
+
<Message severity="error" :closable="false" class="form-error-message">
|
|
171
|
+
{{ fetchErrorMessage }}
|
|
172
|
+
</Message>
|
|
173
|
+
</slot>
|
|
174
|
+
</template>
|
|
175
|
+
|
|
176
|
+
<!-- Form Content -->
|
|
177
|
+
<template v-else>
|
|
178
|
+
<!-- Validation Error Summary -->
|
|
179
|
+
<Message
|
|
180
|
+
v-if="errorSummary && errorSummary.length > 0"
|
|
181
|
+
severity="warn"
|
|
182
|
+
:closable="false"
|
|
183
|
+
class="validation-summary"
|
|
184
|
+
>
|
|
185
|
+
<ul class="validation-errors">
|
|
186
|
+
<li v-for="error in errorSummary" :key="error.field">
|
|
187
|
+
<strong>{{ error.label }}:</strong> {{ error.message }}
|
|
188
|
+
</li>
|
|
189
|
+
</ul>
|
|
190
|
+
</Message>
|
|
191
|
+
|
|
192
|
+
<!-- Card wrapper or direct content -->
|
|
193
|
+
<Card v-if="cardWrapper">
|
|
194
|
+
<template #content>
|
|
195
|
+
<slot name="fields" />
|
|
196
|
+
|
|
197
|
+
<!-- Form Actions (in footer) -->
|
|
198
|
+
<template v-if="showFormActions">
|
|
199
|
+
<slot name="footer">
|
|
200
|
+
<FormActions
|
|
201
|
+
:isEdit="isEdit"
|
|
202
|
+
:saving="saving"
|
|
203
|
+
:dirty="dirty"
|
|
204
|
+
:showSaveAndClose="showSaveAndClose"
|
|
205
|
+
@save="emit('save')"
|
|
206
|
+
@saveAndClose="emit('saveAndClose')"
|
|
207
|
+
@cancel="emit('cancel')"
|
|
208
|
+
/>
|
|
209
|
+
</slot>
|
|
210
|
+
</template>
|
|
211
|
+
</template>
|
|
212
|
+
</Card>
|
|
213
|
+
|
|
214
|
+
<template v-else>
|
|
215
|
+
<slot name="fields" />
|
|
216
|
+
|
|
217
|
+
<!-- Form Actions (in footer) -->
|
|
218
|
+
<template v-if="showFormActions">
|
|
219
|
+
<slot name="footer">
|
|
220
|
+
<FormActions
|
|
221
|
+
:isEdit="isEdit"
|
|
222
|
+
:saving="saving"
|
|
223
|
+
:dirty="dirty"
|
|
224
|
+
:showSaveAndClose="showSaveAndClose"
|
|
225
|
+
@save="emit('save')"
|
|
226
|
+
@saveAndClose="emit('saveAndClose')"
|
|
227
|
+
@cancel="emit('cancel')"
|
|
228
|
+
/>
|
|
229
|
+
</slot>
|
|
230
|
+
</template>
|
|
231
|
+
</template>
|
|
232
|
+
</template>
|
|
233
|
+
|
|
234
|
+
<!-- Unsaved Changes Dialog -->
|
|
235
|
+
<UnsavedChangesDialog
|
|
236
|
+
v-if="guardDialog"
|
|
237
|
+
v-model:visible="guardDialog.visible"
|
|
238
|
+
:saving="saving"
|
|
239
|
+
:hasOnSave="!!guardDialog.onSave"
|
|
240
|
+
@saveAndLeave="onGuardSaveAndLeave"
|
|
241
|
+
@leave="onGuardLeave"
|
|
242
|
+
@stay="onGuardStay"
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
246
|
+
|
|
247
|
+
<style scoped>
|
|
248
|
+
.form-page {
|
|
249
|
+
display: flex;
|
|
250
|
+
flex-direction: column;
|
|
251
|
+
gap: 1rem;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.loading-state {
|
|
255
|
+
display: flex;
|
|
256
|
+
justify-content: center;
|
|
257
|
+
padding: 3rem;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.form-error-message {
|
|
261
|
+
margin: 1rem 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.validation-summary {
|
|
265
|
+
margin-bottom: 1rem;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.validation-errors {
|
|
269
|
+
margin: 0;
|
|
270
|
+
padding-left: 1.5rem;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.validation-errors li {
|
|
274
|
+
margin: 0.25rem 0;
|
|
275
|
+
}
|
|
276
|
+
</style>
|
package/src/components/index.js
CHANGED
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
// Layout
|
|
6
6
|
export { default as AppLayout } from './layout/AppLayout.vue'
|
|
7
|
+
export { default as BaseLayout } from './layout/BaseLayout.vue'
|
|
7
8
|
export { default as PageLayout } from './layout/PageLayout.vue'
|
|
8
9
|
export { default as PageHeader } from './layout/PageHeader.vue'
|
|
9
10
|
export { default as Breadcrumb } from './layout/Breadcrumb.vue'
|
|
10
11
|
export { default as PageNav } from './layout/PageNav.vue'
|
|
12
|
+
export { default as Zone } from './layout/Zone.vue'
|
|
13
|
+
|
|
14
|
+
// Default zone components
|
|
15
|
+
export { default as DefaultHeader } from './layout/defaults/DefaultHeader.vue'
|
|
16
|
+
export { default as DefaultMenu } from './layout/defaults/DefaultMenu.vue'
|
|
17
|
+
export { default as DefaultFooter } from './layout/defaults/DefaultFooter.vue'
|
|
18
|
+
export { default as DefaultUserInfo } from './layout/defaults/DefaultUserInfo.vue'
|
|
19
|
+
export { default as DefaultBreadcrumb } from './layout/defaults/DefaultBreadcrumb.vue'
|
|
20
|
+
export { default as DefaultToaster } from './layout/defaults/DefaultToaster.vue'
|
|
11
21
|
|
|
12
22
|
// Forms
|
|
23
|
+
export { default as FormPage } from './forms/FormPage.vue'
|
|
13
24
|
export { default as FormField } from './forms/FormField.vue'
|
|
14
25
|
export { default as FormActions } from './forms/FormActions.vue'
|
|
15
26
|
export { default as FormTabs } from './forms/FormTabs.vue'
|