qdadm 0.15.1 → 0.17.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/BoolCell.vue +11 -6
- 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/AppLayout.vue +18 -9
- 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/PageHeader.vue +6 -9
- package/src/components/layout/PageNav.vue +15 -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 +8 -6
- package/src/composables/useBreadcrumb.js +9 -5
- 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/useNavContext.js +372 -0
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/usePageTitle.js +59 -0
- 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 +314 -16
- package/src/entity/auth/AuthAdapter.js +125 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/index.js +11 -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 +12 -0
- package/src/kernel/Kernel.js +141 -4
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +124 -6
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/plugin.js +5 -0
- package/src/zones/ZoneRegistry.js +821 -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
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFormPageBuilder - Unified procedural builder for CRUD form pages
|
|
3
|
+
*
|
|
4
|
+
* Provides a declarative/procedural API to build form pages with:
|
|
5
|
+
* - Mode detection (create vs edit from route params)
|
|
6
|
+
* - Auto-load entity data in edit mode
|
|
7
|
+
* - Auto-generate form fields from EntityManager.fields schema
|
|
8
|
+
* - Dirty state tracking for unsaved changes
|
|
9
|
+
* - Validation with schema-derived and custom validators
|
|
10
|
+
* - Permission-aware actions (save, delete)
|
|
11
|
+
* - FormPage component binding via props/events
|
|
12
|
+
*
|
|
13
|
+
* ## Basic Usage
|
|
14
|
+
*
|
|
15
|
+
* ```js
|
|
16
|
+
* const form = useFormPageBuilder({ entity: 'books' })
|
|
17
|
+
* form.addSaveAction()
|
|
18
|
+
* form.addDeleteAction()
|
|
19
|
+
*
|
|
20
|
+
* <FormPage v-bind="form.props" v-on="form.events">
|
|
21
|
+
* <template #fields>
|
|
22
|
+
* <FormField v-model="form.data.title" name="title" />
|
|
23
|
+
* </template>
|
|
24
|
+
* </FormPage>
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* ## Auto-generated Fields
|
|
28
|
+
*
|
|
29
|
+
* ```js
|
|
30
|
+
* const form = useFormPageBuilder({ entity: 'books' })
|
|
31
|
+
* form.generateFields() // Auto-generate from manager.fields
|
|
32
|
+
* form.excludeField('internal_id') // Exclude specific fields
|
|
33
|
+
* form.addField('custom', { type: 'text', label: 'Custom' }) // Manual override
|
|
34
|
+
*
|
|
35
|
+
* // Access generated fields
|
|
36
|
+
* form.fields // Computed array of field configs
|
|
37
|
+
* form.getFieldConfig('title') // Get specific field config
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* ## Validation
|
|
41
|
+
*
|
|
42
|
+
* ```js
|
|
43
|
+
* const form = useFormPageBuilder({ entity: 'books' })
|
|
44
|
+
* form.generateFields()
|
|
45
|
+
*
|
|
46
|
+
* // Add custom validator
|
|
47
|
+
* form.addField('email', { validate: (v) => v.includes('@') || 'Invalid email' })
|
|
48
|
+
*
|
|
49
|
+
* // Validate manually
|
|
50
|
+
* if (form.validate()) {
|
|
51
|
+
* await form.submit()
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* // Errors are available
|
|
55
|
+
* form.errors.value // { email: 'Invalid email' }
|
|
56
|
+
* form.getFieldError('email') // 'Invalid email'
|
|
57
|
+
* form.hasErrors.value // true
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* ## Field Type Mapping
|
|
61
|
+
*
|
|
62
|
+
* Schema types are mapped to input component types:
|
|
63
|
+
* - text, string, email, password -> text input
|
|
64
|
+
* - number, integer, float -> number input
|
|
65
|
+
* - boolean, checkbox -> checkbox/toggle
|
|
66
|
+
* - select, dropdown -> select/dropdown
|
|
67
|
+
* - date, datetime -> date picker
|
|
68
|
+
* - textarea -> textarea
|
|
69
|
+
*
|
|
70
|
+
* ## Features
|
|
71
|
+
*
|
|
72
|
+
* - Mode detection: `/books/new` = create, `/books/:id/edit` = edit
|
|
73
|
+
* - Auto-load on edit mode via EntityManager.get()
|
|
74
|
+
* - Auto-generate fields from EntityManager.fields schema
|
|
75
|
+
* - Dirty state tracking (from useDirtyState)
|
|
76
|
+
* - Validation with required, type-based, and custom validators
|
|
77
|
+
* - Unsaved changes guard modal
|
|
78
|
+
* - Permission-aware save/delete actions via EntityManager.canUpdate/canDelete
|
|
79
|
+
*/
|
|
80
|
+
import { ref, computed, watch, onMounted, inject, provide } from 'vue'
|
|
81
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
82
|
+
import { useToast } from 'primevue/usetoast'
|
|
83
|
+
import { useConfirm } from 'primevue/useconfirm'
|
|
84
|
+
import { useDirtyState } from './useDirtyState'
|
|
85
|
+
import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
|
|
86
|
+
import { useBreadcrumb } from './useBreadcrumb'
|
|
87
|
+
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
88
|
+
import { deepClone } from '../utils/transformers'
|
|
89
|
+
import { onUnmounted } from 'vue'
|
|
90
|
+
|
|
91
|
+
export function useFormPageBuilder(config = {}) {
|
|
92
|
+
const {
|
|
93
|
+
entity,
|
|
94
|
+
// Mode detection
|
|
95
|
+
getId = null, // Custom function to extract ID from route
|
|
96
|
+
createRouteSuffix = 'create', // Route name suffix for create mode
|
|
97
|
+
editRouteSuffix = 'edit', // Route name suffix for edit mode
|
|
98
|
+
// Form options
|
|
99
|
+
loadOnMount = true,
|
|
100
|
+
enableGuard = true,
|
|
101
|
+
redirectOnCreate = true, // Redirect to edit mode after create
|
|
102
|
+
usePatch = false, // Use PATCH instead of PUT for updates
|
|
103
|
+
// Hooks for custom behavior
|
|
104
|
+
transformLoad = (data) => data,
|
|
105
|
+
transformSave = (data) => data,
|
|
106
|
+
onLoadSuccess = null,
|
|
107
|
+
onSaveSuccess = null,
|
|
108
|
+
onDeleteSuccess = null,
|
|
109
|
+
// Validation options
|
|
110
|
+
validateOnBlur = true, // Validate field on blur
|
|
111
|
+
validateOnSubmit = true, // Validate all fields before submit
|
|
112
|
+
showErrorSummary = false // Show error summary at top of form
|
|
113
|
+
} = config
|
|
114
|
+
|
|
115
|
+
const router = useRouter()
|
|
116
|
+
const route = useRoute()
|
|
117
|
+
const toast = useToast()
|
|
118
|
+
const confirm = useConfirm()
|
|
119
|
+
|
|
120
|
+
// Get EntityManager via orchestrator
|
|
121
|
+
const orchestrator = inject('qdadmOrchestrator')
|
|
122
|
+
if (!orchestrator) {
|
|
123
|
+
throw new Error('[qdadm] Orchestrator not provided. Make sure to use createQdadm() with entityFactory.')
|
|
124
|
+
}
|
|
125
|
+
const manager = orchestrator.get(entity)
|
|
126
|
+
|
|
127
|
+
// Provide entity context for child components (e.g., SeverityTag auto-discovery)
|
|
128
|
+
provide('mainEntity', entity)
|
|
129
|
+
|
|
130
|
+
// Read config from manager with option overrides
|
|
131
|
+
const entityName = config.entityName ?? manager.label
|
|
132
|
+
const routePrefix = config.routePrefix ?? manager.routePrefix
|
|
133
|
+
const initialData = config.initialData ?? manager.getInitialData()
|
|
134
|
+
|
|
135
|
+
// ============ MODE DETECTION ============
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract entity ID from route params
|
|
139
|
+
* Supports: /books/:id/edit, /books/:id, /books/new
|
|
140
|
+
*/
|
|
141
|
+
const entityId = computed(() => {
|
|
142
|
+
if (getId) return getId()
|
|
143
|
+
return route.params.id || route.params.key || null
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detect form mode: 'create' or 'edit'
|
|
148
|
+
* Based on route name or presence of ID
|
|
149
|
+
*/
|
|
150
|
+
const mode = computed(() => {
|
|
151
|
+
const routeName = route.name || ''
|
|
152
|
+
if (routeName.endsWith(createRouteSuffix) || routeName.endsWith('-new')) {
|
|
153
|
+
return 'create'
|
|
154
|
+
}
|
|
155
|
+
if (entityId.value) {
|
|
156
|
+
return 'edit'
|
|
157
|
+
}
|
|
158
|
+
return 'create'
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const isEdit = computed(() => mode.value === 'edit')
|
|
162
|
+
const isCreate = computed(() => mode.value === 'create')
|
|
163
|
+
|
|
164
|
+
// ============ STATE ============
|
|
165
|
+
|
|
166
|
+
const data = ref(deepClone(initialData))
|
|
167
|
+
const originalData = ref(null)
|
|
168
|
+
const loading = ref(false)
|
|
169
|
+
const saving = ref(false)
|
|
170
|
+
|
|
171
|
+
// Dirty state getter
|
|
172
|
+
const dirtyStateGetter = config.getDirtyState || (() => ({ form: data.value }))
|
|
173
|
+
|
|
174
|
+
// Dirty state tracking
|
|
175
|
+
const {
|
|
176
|
+
dirty,
|
|
177
|
+
dirtyFields,
|
|
178
|
+
isFieldDirty,
|
|
179
|
+
takeSnapshot,
|
|
180
|
+
checkDirty
|
|
181
|
+
} = useDirtyState(dirtyStateGetter)
|
|
182
|
+
|
|
183
|
+
// Provide isFieldDirty and dirtyFields for child components (FormField)
|
|
184
|
+
provide('isFieldDirty', isFieldDirty)
|
|
185
|
+
provide('dirtyFields', dirtyFields)
|
|
186
|
+
|
|
187
|
+
// Watch for changes to update dirty state
|
|
188
|
+
watch(data, checkDirty, { deep: true })
|
|
189
|
+
|
|
190
|
+
// ============ VALIDATION STATE ============
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Map of field errors: { fieldName: 'Error message' }
|
|
194
|
+
*/
|
|
195
|
+
const errors = ref({})
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether the form has been submitted at least once (for showing all errors)
|
|
199
|
+
*/
|
|
200
|
+
const submitted = ref(false)
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Computed: whether form has any errors
|
|
204
|
+
*/
|
|
205
|
+
const hasErrors = computed(() => Object.keys(errors.value).length > 0)
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Computed: list of error messages for summary display
|
|
209
|
+
*/
|
|
210
|
+
const errorSummary = computed(() => {
|
|
211
|
+
return Object.entries(errors.value).map(([field, message]) => {
|
|
212
|
+
const fieldConfig = fieldsMap.value.get(field)
|
|
213
|
+
const label = fieldConfig?.label || snakeCaseToTitle(field)
|
|
214
|
+
return { field, label, message }
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Provide validation state for child components (FormField)
|
|
219
|
+
provide('getFieldError', (name) => errors.value[name] || null)
|
|
220
|
+
provide('formSubmitted', submitted)
|
|
221
|
+
|
|
222
|
+
// ============ UNSAVED CHANGES GUARD ============
|
|
223
|
+
|
|
224
|
+
let guardDialog = null
|
|
225
|
+
if (enableGuard) {
|
|
226
|
+
const { guardDialog: gd } = useUnsavedChangesGuard(dirty, {
|
|
227
|
+
onSave: () => submit(false)
|
|
228
|
+
})
|
|
229
|
+
guardDialog = gd
|
|
230
|
+
registerGuardDialog(guardDialog)
|
|
231
|
+
onUnmounted(() => unregisterGuardDialog(guardDialog))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ============ BREADCRUMB ============
|
|
235
|
+
|
|
236
|
+
const { breadcrumbItems } = useBreadcrumb({ entity: data })
|
|
237
|
+
|
|
238
|
+
// ============ LOADING ============
|
|
239
|
+
|
|
240
|
+
async function load() {
|
|
241
|
+
if (!isEdit.value) {
|
|
242
|
+
data.value = deepClone(initialData)
|
|
243
|
+
takeSnapshot()
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
loading.value = true
|
|
248
|
+
try {
|
|
249
|
+
const responseData = await manager.get(entityId.value)
|
|
250
|
+
const transformed = transformLoad(responseData)
|
|
251
|
+
data.value = transformed
|
|
252
|
+
originalData.value = deepClone(transformed)
|
|
253
|
+
takeSnapshot()
|
|
254
|
+
|
|
255
|
+
if (onLoadSuccess) {
|
|
256
|
+
await onLoadSuccess(transformed)
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
toast.add({
|
|
260
|
+
severity: 'error',
|
|
261
|
+
summary: 'Error',
|
|
262
|
+
detail: error.response?.data?.detail || `Failed to load ${entityName}`,
|
|
263
|
+
life: 5000
|
|
264
|
+
})
|
|
265
|
+
} finally {
|
|
266
|
+
loading.value = false
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============ SUBMIT ============
|
|
271
|
+
|
|
272
|
+
async function submit(andClose = true) {
|
|
273
|
+
// Mark as submitted (shows all errors in UI)
|
|
274
|
+
submitted.value = true
|
|
275
|
+
|
|
276
|
+
// Validate before submit if enabled
|
|
277
|
+
if (validateOnSubmit && !validate()) {
|
|
278
|
+
toast.add({
|
|
279
|
+
severity: 'warn',
|
|
280
|
+
summary: 'Validation Error',
|
|
281
|
+
detail: 'Please fix the errors before saving',
|
|
282
|
+
life: 3000
|
|
283
|
+
})
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
saving.value = true
|
|
288
|
+
try {
|
|
289
|
+
const payload = transformSave(deepClone(data.value))
|
|
290
|
+
|
|
291
|
+
let responseData
|
|
292
|
+
if (isEdit.value) {
|
|
293
|
+
if (usePatch) {
|
|
294
|
+
responseData = await manager.patch(entityId.value, payload)
|
|
295
|
+
} else {
|
|
296
|
+
responseData = await manager.update(entityId.value, payload)
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
responseData = await manager.create(payload)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
toast.add({
|
|
303
|
+
severity: 'success',
|
|
304
|
+
summary: 'Success',
|
|
305
|
+
detail: `${entityName} ${isEdit.value ? 'updated' : 'created'} successfully`,
|
|
306
|
+
life: 3000
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Update data and snapshot
|
|
310
|
+
const savedData = transformLoad(responseData)
|
|
311
|
+
data.value = savedData
|
|
312
|
+
originalData.value = deepClone(savedData)
|
|
313
|
+
takeSnapshot()
|
|
314
|
+
|
|
315
|
+
if (onSaveSuccess) {
|
|
316
|
+
await onSaveSuccess(responseData, andClose)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (andClose) {
|
|
320
|
+
router.push({ name: routePrefix })
|
|
321
|
+
} else if (!isEdit.value && redirectOnCreate) {
|
|
322
|
+
// Redirect to edit mode after create
|
|
323
|
+
const newId = responseData[manager.idField] || responseData.id || responseData.key
|
|
324
|
+
router.replace({ name: `${routePrefix}-${editRouteSuffix}`, params: { id: newId } })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return responseData
|
|
328
|
+
} catch (error) {
|
|
329
|
+
toast.add({
|
|
330
|
+
severity: 'error',
|
|
331
|
+
summary: 'Error',
|
|
332
|
+
detail: error.response?.data?.detail || `Failed to save ${entityName}`,
|
|
333
|
+
life: 5000
|
|
334
|
+
})
|
|
335
|
+
throw error
|
|
336
|
+
} finally {
|
|
337
|
+
saving.value = false
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============ DELETE ============
|
|
342
|
+
|
|
343
|
+
async function remove() {
|
|
344
|
+
if (!isEdit.value) return
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await manager.delete(entityId.value)
|
|
348
|
+
toast.add({
|
|
349
|
+
severity: 'success',
|
|
350
|
+
summary: 'Deleted',
|
|
351
|
+
detail: `${entityName} deleted successfully`,
|
|
352
|
+
life: 3000
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
if (onDeleteSuccess) {
|
|
356
|
+
await onDeleteSuccess()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
router.push({ name: routePrefix })
|
|
360
|
+
} catch (error) {
|
|
361
|
+
toast.add({
|
|
362
|
+
severity: 'error',
|
|
363
|
+
summary: 'Error',
|
|
364
|
+
detail: error.response?.data?.detail || `Failed to delete ${entityName}`,
|
|
365
|
+
life: 5000
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function confirmDelete() {
|
|
371
|
+
const label = manager.getEntityLabel(data.value) || entityId.value
|
|
372
|
+
confirm.require({
|
|
373
|
+
message: `Delete ${entityName} "${label}"?`,
|
|
374
|
+
header: 'Confirm Delete',
|
|
375
|
+
icon: 'pi pi-exclamation-triangle',
|
|
376
|
+
acceptClass: 'p-button-danger',
|
|
377
|
+
accept: remove
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ============ RESET ============
|
|
382
|
+
|
|
383
|
+
function reset() {
|
|
384
|
+
if (originalData.value) {
|
|
385
|
+
data.value = deepClone(originalData.value)
|
|
386
|
+
} else {
|
|
387
|
+
data.value = deepClone(initialData)
|
|
388
|
+
}
|
|
389
|
+
takeSnapshot()
|
|
390
|
+
// Clear validation state on reset
|
|
391
|
+
errors.value = {}
|
|
392
|
+
submitted.value = false
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============ NAVIGATION ============
|
|
396
|
+
|
|
397
|
+
function cancel() {
|
|
398
|
+
router.push({ name: routePrefix })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function goToList() {
|
|
402
|
+
router.push({ name: routePrefix })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ============ FIELDS ============
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Map of field configurations
|
|
409
|
+
* Key: field name, Value: resolved field config
|
|
410
|
+
*/
|
|
411
|
+
const fieldsMap = ref(new Map())
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Ordered list of field names for rendering
|
|
415
|
+
*/
|
|
416
|
+
const fieldOrder = ref([])
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Set of excluded field names
|
|
420
|
+
*/
|
|
421
|
+
const excludedFields = ref(new Set())
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Type mapping from schema types to input component types
|
|
425
|
+
* Maps EntityManager field types to form input types
|
|
426
|
+
*/
|
|
427
|
+
const TYPE_MAPPINGS = {
|
|
428
|
+
text: 'text',
|
|
429
|
+
string: 'text',
|
|
430
|
+
email: 'email',
|
|
431
|
+
password: 'password',
|
|
432
|
+
number: 'number',
|
|
433
|
+
integer: 'number',
|
|
434
|
+
float: 'number',
|
|
435
|
+
boolean: 'boolean',
|
|
436
|
+
checkbox: 'boolean',
|
|
437
|
+
select: 'select',
|
|
438
|
+
dropdown: 'select',
|
|
439
|
+
date: 'date',
|
|
440
|
+
datetime: 'datetime',
|
|
441
|
+
textarea: 'textarea',
|
|
442
|
+
array: 'array',
|
|
443
|
+
object: 'object'
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Generate form fields from EntityManager.fields schema
|
|
448
|
+
*
|
|
449
|
+
* Reads the manager's fields definition and creates form field configs.
|
|
450
|
+
* Fields marked with editable: false are skipped.
|
|
451
|
+
* Respects excludeField() calls made before generateFields().
|
|
452
|
+
*
|
|
453
|
+
* @param {object} options - Generation options
|
|
454
|
+
* @param {string[]} [options.only] - Only include these fields
|
|
455
|
+
* @param {string[]} [options.exclude] - Exclude these fields (merged with excludeField calls)
|
|
456
|
+
* @returns {object} - The builder instance for chaining
|
|
457
|
+
*/
|
|
458
|
+
function generateFields(options = {}) {
|
|
459
|
+
const { only = null, exclude = [] } = options
|
|
460
|
+
|
|
461
|
+
// Merge exclude option with excludedFields set
|
|
462
|
+
const allExcluded = new Set([...excludedFields.value, ...exclude])
|
|
463
|
+
|
|
464
|
+
// Get form-editable fields from manager
|
|
465
|
+
const formFields = manager.getFormFields()
|
|
466
|
+
|
|
467
|
+
for (const fieldDef of formFields) {
|
|
468
|
+
const { name, ...fieldConfig } = fieldDef
|
|
469
|
+
|
|
470
|
+
// Skip if not in 'only' list (when specified)
|
|
471
|
+
if (only && !only.includes(name)) continue
|
|
472
|
+
|
|
473
|
+
// Skip excluded fields
|
|
474
|
+
if (allExcluded.has(name)) continue
|
|
475
|
+
|
|
476
|
+
// Skip if already added manually (manual overrides take precedence)
|
|
477
|
+
if (fieldsMap.value.has(name)) continue
|
|
478
|
+
|
|
479
|
+
// Build resolved field config
|
|
480
|
+
const resolvedConfig = resolveFieldConfig(name, fieldConfig)
|
|
481
|
+
fieldsMap.value.set(name, resolvedConfig)
|
|
482
|
+
fieldOrder.value.push(name)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return builderApi
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolve field configuration from schema definition
|
|
490
|
+
*
|
|
491
|
+
* @param {string} name - Field name
|
|
492
|
+
* @param {object} fieldConfig - Raw field config from schema
|
|
493
|
+
* @returns {object} - Resolved field configuration
|
|
494
|
+
*/
|
|
495
|
+
function resolveFieldConfig(name, fieldConfig) {
|
|
496
|
+
const {
|
|
497
|
+
type = 'text',
|
|
498
|
+
label = null,
|
|
499
|
+
required = false,
|
|
500
|
+
default: defaultValue,
|
|
501
|
+
options = null,
|
|
502
|
+
placeholder = null,
|
|
503
|
+
disabled = false,
|
|
504
|
+
readonly = false,
|
|
505
|
+
...rest
|
|
506
|
+
} = fieldConfig
|
|
507
|
+
|
|
508
|
+
// Map schema type to input type
|
|
509
|
+
const inputType = TYPE_MAPPINGS[type] || 'text'
|
|
510
|
+
|
|
511
|
+
// Generate label from field name if not provided (snake_case to Title Case)
|
|
512
|
+
const resolvedLabel = label || snakeCaseToTitle(name)
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
name,
|
|
516
|
+
type: inputType,
|
|
517
|
+
schemaType: type, // Preserve original schema type
|
|
518
|
+
label: resolvedLabel,
|
|
519
|
+
required,
|
|
520
|
+
default: defaultValue,
|
|
521
|
+
options,
|
|
522
|
+
placeholder,
|
|
523
|
+
disabled,
|
|
524
|
+
readonly,
|
|
525
|
+
...rest
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Add or override a single field configuration
|
|
531
|
+
*
|
|
532
|
+
* @param {string} name - Field name
|
|
533
|
+
* @param {object} fieldConfig - Field configuration
|
|
534
|
+
* @param {object} [options] - Options
|
|
535
|
+
* @param {string} [options.after] - Insert after this field
|
|
536
|
+
* @param {string} [options.before] - Insert before this field
|
|
537
|
+
* @returns {object} - The builder instance for chaining
|
|
538
|
+
*/
|
|
539
|
+
function addField(name, fieldConfig, options = {}) {
|
|
540
|
+
const { after = null, before = null } = options
|
|
541
|
+
|
|
542
|
+
// Merge with existing schema config if available
|
|
543
|
+
const schemaConfig = manager.getFieldConfig(name) || {}
|
|
544
|
+
const resolvedConfig = resolveFieldConfig(name, { ...schemaConfig, ...fieldConfig })
|
|
545
|
+
|
|
546
|
+
fieldsMap.value.set(name, resolvedConfig)
|
|
547
|
+
|
|
548
|
+
// Handle ordering
|
|
549
|
+
const currentIndex = fieldOrder.value.indexOf(name)
|
|
550
|
+
if (currentIndex !== -1) {
|
|
551
|
+
fieldOrder.value.splice(currentIndex, 1)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (after) {
|
|
555
|
+
const afterIndex = fieldOrder.value.indexOf(after)
|
|
556
|
+
if (afterIndex !== -1) {
|
|
557
|
+
fieldOrder.value.splice(afterIndex + 1, 0, name)
|
|
558
|
+
} else {
|
|
559
|
+
fieldOrder.value.push(name)
|
|
560
|
+
}
|
|
561
|
+
} else if (before) {
|
|
562
|
+
const beforeIndex = fieldOrder.value.indexOf(before)
|
|
563
|
+
if (beforeIndex !== -1) {
|
|
564
|
+
fieldOrder.value.splice(beforeIndex, 0, name)
|
|
565
|
+
} else {
|
|
566
|
+
fieldOrder.value.push(name)
|
|
567
|
+
}
|
|
568
|
+
} else if (currentIndex === -1) {
|
|
569
|
+
// New field, add at end
|
|
570
|
+
fieldOrder.value.push(name)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return builderApi
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Exclude a field from generation
|
|
578
|
+
* Call before generateFields() or use exclude option
|
|
579
|
+
*
|
|
580
|
+
* @param {string} name - Field name to exclude
|
|
581
|
+
* @returns {object} - The builder instance for chaining
|
|
582
|
+
*/
|
|
583
|
+
function excludeField(name) {
|
|
584
|
+
excludedFields.value.add(name)
|
|
585
|
+
// Also remove from existing fields if already generated
|
|
586
|
+
fieldsMap.value.delete(name)
|
|
587
|
+
const idx = fieldOrder.value.indexOf(name)
|
|
588
|
+
if (idx !== -1) {
|
|
589
|
+
fieldOrder.value.splice(idx, 1)
|
|
590
|
+
}
|
|
591
|
+
return builderApi
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Remove a field from the form
|
|
596
|
+
*
|
|
597
|
+
* @param {string} name - Field name to remove
|
|
598
|
+
* @returns {object} - The builder instance for chaining
|
|
599
|
+
*/
|
|
600
|
+
function removeField(name) {
|
|
601
|
+
fieldsMap.value.delete(name)
|
|
602
|
+
const idx = fieldOrder.value.indexOf(name)
|
|
603
|
+
if (idx !== -1) {
|
|
604
|
+
fieldOrder.value.splice(idx, 1)
|
|
605
|
+
}
|
|
606
|
+
return builderApi
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Reorder fields
|
|
611
|
+
*
|
|
612
|
+
* @param {string[]} order - Array of field names in desired order
|
|
613
|
+
* @returns {object} - The builder instance for chaining
|
|
614
|
+
*/
|
|
615
|
+
function setFieldOrder(order) {
|
|
616
|
+
// Only include fields that exist in fieldsMap
|
|
617
|
+
fieldOrder.value = order.filter(name => fieldsMap.value.has(name))
|
|
618
|
+
return builderApi
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Move a field to a specific position
|
|
623
|
+
*
|
|
624
|
+
* @param {string} name - Field name
|
|
625
|
+
* @param {object} position - Position options
|
|
626
|
+
* @param {string} [position.after] - Move after this field
|
|
627
|
+
* @param {string} [position.before] - Move before this field
|
|
628
|
+
* @returns {object} - The builder instance for chaining
|
|
629
|
+
*/
|
|
630
|
+
function moveField(name, position) {
|
|
631
|
+
const { after = null, before = null } = position
|
|
632
|
+
|
|
633
|
+
const currentIndex = fieldOrder.value.indexOf(name)
|
|
634
|
+
if (currentIndex === -1) return builderApi
|
|
635
|
+
|
|
636
|
+
fieldOrder.value.splice(currentIndex, 1)
|
|
637
|
+
|
|
638
|
+
if (after) {
|
|
639
|
+
const afterIndex = fieldOrder.value.indexOf(after)
|
|
640
|
+
if (afterIndex !== -1) {
|
|
641
|
+
fieldOrder.value.splice(afterIndex + 1, 0, name)
|
|
642
|
+
} else {
|
|
643
|
+
fieldOrder.value.push(name)
|
|
644
|
+
}
|
|
645
|
+
} else if (before) {
|
|
646
|
+
const beforeIndex = fieldOrder.value.indexOf(before)
|
|
647
|
+
if (beforeIndex !== -1) {
|
|
648
|
+
fieldOrder.value.splice(beforeIndex, 0, name)
|
|
649
|
+
} else {
|
|
650
|
+
fieldOrder.value.unshift(name)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return builderApi
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Get field configuration
|
|
659
|
+
*
|
|
660
|
+
* @param {string} name - Field name
|
|
661
|
+
* @returns {object|undefined} - Field configuration
|
|
662
|
+
*/
|
|
663
|
+
function getFieldConfig(name) {
|
|
664
|
+
return fieldsMap.value.get(name)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Get all fields in order
|
|
669
|
+
*
|
|
670
|
+
* @returns {Array<object>} - Array of field configurations
|
|
671
|
+
*/
|
|
672
|
+
function getFields() {
|
|
673
|
+
return fieldOrder.value.map(name => fieldsMap.value.get(name)).filter(Boolean)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Computed property for ordered fields
|
|
678
|
+
*/
|
|
679
|
+
const fields = computed(() => getFields())
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Helper: Convert snake_case to Title Case
|
|
683
|
+
*/
|
|
684
|
+
function snakeCaseToTitle(str) {
|
|
685
|
+
if (!str) return ''
|
|
686
|
+
return str
|
|
687
|
+
.split('_')
|
|
688
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
689
|
+
.join(' ')
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ============ VALIDATION ============
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Built-in validators by type
|
|
696
|
+
* Each validator returns true if valid, or error message string if invalid
|
|
697
|
+
*/
|
|
698
|
+
const TYPE_VALIDATORS = {
|
|
699
|
+
email: (value) => {
|
|
700
|
+
if (!value) return true // Empty handled by required
|
|
701
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
702
|
+
return emailRegex.test(value) || 'Invalid email address'
|
|
703
|
+
},
|
|
704
|
+
number: (value) => {
|
|
705
|
+
if (value === null || value === undefined || value === '') return true
|
|
706
|
+
return !isNaN(Number(value)) || 'Must be a number'
|
|
707
|
+
},
|
|
708
|
+
integer: (value) => {
|
|
709
|
+
if (value === null || value === undefined || value === '') return true
|
|
710
|
+
return Number.isInteger(Number(value)) || 'Must be an integer'
|
|
711
|
+
},
|
|
712
|
+
url: (value) => {
|
|
713
|
+
if (!value) return true
|
|
714
|
+
try {
|
|
715
|
+
new URL(value)
|
|
716
|
+
return true
|
|
717
|
+
} catch {
|
|
718
|
+
return 'Invalid URL'
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Check if a value is "empty" for required validation
|
|
725
|
+
* @param {any} value
|
|
726
|
+
* @returns {boolean}
|
|
727
|
+
*/
|
|
728
|
+
function isEmpty(value) {
|
|
729
|
+
if (value === null || value === undefined) return true
|
|
730
|
+
if (typeof value === 'string') return value.trim() === ''
|
|
731
|
+
if (Array.isArray(value)) return value.length === 0
|
|
732
|
+
return false
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Validate a single field
|
|
737
|
+
*
|
|
738
|
+
* @param {string} name - Field name
|
|
739
|
+
* @returns {string|null} - Error message or null if valid
|
|
740
|
+
*/
|
|
741
|
+
function validateField(name) {
|
|
742
|
+
const fieldConfig = fieldsMap.value.get(name)
|
|
743
|
+
if (!fieldConfig) return null
|
|
744
|
+
|
|
745
|
+
const value = data.value[name]
|
|
746
|
+
|
|
747
|
+
// Required validation
|
|
748
|
+
if (fieldConfig.required && isEmpty(value)) {
|
|
749
|
+
const error = `${fieldConfig.label} is required`
|
|
750
|
+
errors.value = { ...errors.value, [name]: error }
|
|
751
|
+
return error
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Type-based validation
|
|
755
|
+
const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
|
|
756
|
+
if (typeValidator) {
|
|
757
|
+
const result = typeValidator(value)
|
|
758
|
+
if (result !== true) {
|
|
759
|
+
errors.value = { ...errors.value, [name]: result }
|
|
760
|
+
return result
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Custom validator
|
|
765
|
+
if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
|
|
766
|
+
const result = fieldConfig.validate(value, data.value)
|
|
767
|
+
if (result !== true && result !== undefined && result !== null) {
|
|
768
|
+
const error = typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
|
|
769
|
+
errors.value = { ...errors.value, [name]: error }
|
|
770
|
+
return error
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Clear error if valid
|
|
775
|
+
if (errors.value[name]) {
|
|
776
|
+
const { [name]: _, ...rest } = errors.value
|
|
777
|
+
errors.value = rest
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return null
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Validate all fields in the form
|
|
785
|
+
*
|
|
786
|
+
* @returns {boolean} - true if form is valid, false otherwise
|
|
787
|
+
*/
|
|
788
|
+
function validate() {
|
|
789
|
+
const newErrors = {}
|
|
790
|
+
|
|
791
|
+
for (const fieldName of fieldOrder.value) {
|
|
792
|
+
const fieldConfig = fieldsMap.value.get(fieldName)
|
|
793
|
+
if (!fieldConfig) continue
|
|
794
|
+
|
|
795
|
+
const value = data.value[fieldName]
|
|
796
|
+
|
|
797
|
+
// Required validation
|
|
798
|
+
if (fieldConfig.required && isEmpty(value)) {
|
|
799
|
+
newErrors[fieldName] = `${fieldConfig.label} is required`
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Type-based validation
|
|
804
|
+
const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
|
|
805
|
+
if (typeValidator) {
|
|
806
|
+
const result = typeValidator(value)
|
|
807
|
+
if (result !== true) {
|
|
808
|
+
newErrors[fieldName] = result
|
|
809
|
+
continue
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Custom validator
|
|
814
|
+
if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
|
|
815
|
+
const result = fieldConfig.validate(value, data.value)
|
|
816
|
+
if (result !== true && result !== undefined && result !== null) {
|
|
817
|
+
newErrors[fieldName] = typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
errors.value = newErrors
|
|
823
|
+
return Object.keys(newErrors).length === 0
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Clear all validation errors
|
|
828
|
+
*/
|
|
829
|
+
function clearErrors() {
|
|
830
|
+
errors.value = {}
|
|
831
|
+
submitted.value = false
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Clear error for a specific field
|
|
836
|
+
* @param {string} name - Field name
|
|
837
|
+
*/
|
|
838
|
+
function clearFieldError(name) {
|
|
839
|
+
if (errors.value[name]) {
|
|
840
|
+
const { [name]: _, ...rest } = errors.value
|
|
841
|
+
errors.value = rest
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Get error for a specific field
|
|
847
|
+
* @param {string} name - Field name
|
|
848
|
+
* @returns {string|null}
|
|
849
|
+
*/
|
|
850
|
+
function getFieldError(name) {
|
|
851
|
+
return errors.value[name] || null
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Handle field blur event (for validateOnBlur)
|
|
856
|
+
* @param {string} name - Field name
|
|
857
|
+
*/
|
|
858
|
+
function handleFieldBlur(name) {
|
|
859
|
+
if (validateOnBlur) {
|
|
860
|
+
validateField(name)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Provide handleFieldBlur for child components
|
|
865
|
+
provide('handleFieldBlur', handleFieldBlur)
|
|
866
|
+
|
|
867
|
+
// ============ PERMISSION STATE ============
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Whether the current user can save the form
|
|
871
|
+
* In create mode: checks canCreate()
|
|
872
|
+
* In edit mode: checks canUpdate() for the current record
|
|
873
|
+
*/
|
|
874
|
+
const canSave = computed(() => {
|
|
875
|
+
return isEdit.value
|
|
876
|
+
? manager.canUpdate(data.value)
|
|
877
|
+
: manager.canCreate()
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Whether the current user can delete the current record
|
|
882
|
+
* Always false in create mode (nothing to delete)
|
|
883
|
+
*/
|
|
884
|
+
const canDeleteRecord = computed(() => {
|
|
885
|
+
return isEdit.value && manager.canDelete(data.value)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
// ============ ACTIONS ============
|
|
889
|
+
|
|
890
|
+
const actionsMap = ref(new Map())
|
|
891
|
+
|
|
892
|
+
function addAction(name, actionConfig) {
|
|
893
|
+
actionsMap.value.set(name, { name, ...actionConfig })
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function removeAction(name) {
|
|
897
|
+
actionsMap.value.delete(name)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Get actions with resolved state
|
|
902
|
+
*/
|
|
903
|
+
function getActions() {
|
|
904
|
+
const actions = []
|
|
905
|
+
for (const [, action] of actionsMap.value) {
|
|
906
|
+
if (action.visible && !action.visible({ isEdit: isEdit.value, dirty: dirty.value })) continue
|
|
907
|
+
actions.push({
|
|
908
|
+
...action,
|
|
909
|
+
isLoading: action.loading ? action.loading() : false,
|
|
910
|
+
isDisabled: action.disabled ? action.disabled({ dirty: dirty.value, saving: saving.value }) : false
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
return actions
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const actions = computed(() => getActions())
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Add standard "Save" action
|
|
920
|
+
* Respects manager.canCreate/canUpdate for visibility
|
|
921
|
+
*/
|
|
922
|
+
function addSaveAction(options = {}) {
|
|
923
|
+
const { label, andClose = true } = options
|
|
924
|
+
const actionLabel = label || (andClose ? 'Save & Close' : 'Save')
|
|
925
|
+
|
|
926
|
+
addAction('save', {
|
|
927
|
+
label: actionLabel,
|
|
928
|
+
icon: andClose ? 'pi pi-check-circle' : 'pi pi-check',
|
|
929
|
+
severity: andClose ? 'success' : 'primary',
|
|
930
|
+
onClick: () => submit(andClose),
|
|
931
|
+
visible: () => isEdit.value ? manager.canUpdate() : manager.canCreate(),
|
|
932
|
+
disabled: ({ dirty, saving }) => !dirty || saving,
|
|
933
|
+
loading: () => saving.value
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Add standard "Delete" action
|
|
939
|
+
* Only visible in edit mode, respects manager.canDelete
|
|
940
|
+
*/
|
|
941
|
+
function addDeleteAction(options = {}) {
|
|
942
|
+
const { label = 'Delete' } = options
|
|
943
|
+
|
|
944
|
+
addAction('delete', {
|
|
945
|
+
label,
|
|
946
|
+
icon: 'pi pi-trash',
|
|
947
|
+
severity: 'danger',
|
|
948
|
+
onClick: confirmDelete,
|
|
949
|
+
visible: () => isEdit.value && manager.canDelete(data.value)
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Add standard "Cancel" action
|
|
955
|
+
*/
|
|
956
|
+
function addCancelAction(options = {}) {
|
|
957
|
+
const { label = 'Cancel' } = options
|
|
958
|
+
|
|
959
|
+
addAction('cancel', {
|
|
960
|
+
label,
|
|
961
|
+
icon: 'pi pi-times',
|
|
962
|
+
severity: 'secondary',
|
|
963
|
+
onClick: cancel,
|
|
964
|
+
disabled: ({ saving }) => saving
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ============ TITLE ============
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Entity display label (e.g., "David Berlioz" for a user)
|
|
972
|
+
*/
|
|
973
|
+
const entityLabel = computed(() => {
|
|
974
|
+
return manager.getEntityLabel(data.value)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Auto-generated page title
|
|
979
|
+
* - Edit mode: "Edit Book: The Great Gatsby"
|
|
980
|
+
* - Create mode: "Create Book"
|
|
981
|
+
*/
|
|
982
|
+
const pageTitle = computed(() => {
|
|
983
|
+
if (isEdit.value) {
|
|
984
|
+
const label = entityLabel.value
|
|
985
|
+
return label ? `Edit ${entityName}: ${label}` : `Edit ${entityName}`
|
|
986
|
+
}
|
|
987
|
+
return `Create ${entityName}`
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Structured page title for decorated rendering
|
|
992
|
+
*/
|
|
993
|
+
const pageTitleParts = computed(() => ({
|
|
994
|
+
action: isEdit.value ? 'Edit' : 'Create',
|
|
995
|
+
entityName,
|
|
996
|
+
entityLabel: isEdit.value ? entityLabel.value : null
|
|
997
|
+
}))
|
|
998
|
+
|
|
999
|
+
// Provide title parts for automatic PageHeader consumption
|
|
1000
|
+
provide('qdadmPageTitleParts', pageTitleParts)
|
|
1001
|
+
|
|
1002
|
+
// ============ LIFECYCLE ============
|
|
1003
|
+
|
|
1004
|
+
onMounted(() => {
|
|
1005
|
+
if (loadOnMount) {
|
|
1006
|
+
load()
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
// ============ FORMPAGE PROPS/EVENTS ============
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Props object for FormPage component
|
|
1014
|
+
* Use with v-bind: <FormPage v-bind="form.props">
|
|
1015
|
+
*/
|
|
1016
|
+
const formProps = computed(() => ({
|
|
1017
|
+
// Mode
|
|
1018
|
+
isEdit: isEdit.value,
|
|
1019
|
+
mode: mode.value,
|
|
1020
|
+
|
|
1021
|
+
// State
|
|
1022
|
+
loading: loading.value,
|
|
1023
|
+
saving: saving.value,
|
|
1024
|
+
dirty: dirty.value,
|
|
1025
|
+
|
|
1026
|
+
// Title
|
|
1027
|
+
title: pageTitle.value,
|
|
1028
|
+
titleParts: pageTitleParts.value,
|
|
1029
|
+
|
|
1030
|
+
// Fields (for auto-rendering)
|
|
1031
|
+
fields: fields.value,
|
|
1032
|
+
|
|
1033
|
+
// Actions
|
|
1034
|
+
actions: actions.value,
|
|
1035
|
+
|
|
1036
|
+
// Permissions
|
|
1037
|
+
canSave: canSave.value,
|
|
1038
|
+
canDelete: canDeleteRecord.value,
|
|
1039
|
+
|
|
1040
|
+
// Validation state
|
|
1041
|
+
errors: errors.value,
|
|
1042
|
+
hasErrors: hasErrors.value,
|
|
1043
|
+
errorSummary: showErrorSummary ? errorSummary.value : null,
|
|
1044
|
+
submitted: submitted.value,
|
|
1045
|
+
|
|
1046
|
+
// Guard dialog (for UnsavedChangesDialog)
|
|
1047
|
+
guardDialog
|
|
1048
|
+
}))
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Event handlers for FormPage
|
|
1052
|
+
* Use with v-on: <FormPage v-bind="form.props" v-on="form.events">
|
|
1053
|
+
*/
|
|
1054
|
+
const formEvents = {
|
|
1055
|
+
save: () => submit(false),
|
|
1056
|
+
saveAndClose: () => submit(true),
|
|
1057
|
+
cancel,
|
|
1058
|
+
delete: confirmDelete
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ============ BUILDER API ============
|
|
1062
|
+
// Object reference for method chaining (used by field methods)
|
|
1063
|
+
const builderApi = {
|
|
1064
|
+
// Manager access
|
|
1065
|
+
manager,
|
|
1066
|
+
|
|
1067
|
+
// Mode
|
|
1068
|
+
mode,
|
|
1069
|
+
isEdit,
|
|
1070
|
+
isCreate,
|
|
1071
|
+
entityId,
|
|
1072
|
+
|
|
1073
|
+
// State
|
|
1074
|
+
data,
|
|
1075
|
+
loading,
|
|
1076
|
+
saving,
|
|
1077
|
+
dirty,
|
|
1078
|
+
dirtyFields,
|
|
1079
|
+
originalData,
|
|
1080
|
+
|
|
1081
|
+
// Actions
|
|
1082
|
+
load,
|
|
1083
|
+
submit,
|
|
1084
|
+
cancel,
|
|
1085
|
+
remove,
|
|
1086
|
+
confirmDelete,
|
|
1087
|
+
reset,
|
|
1088
|
+
goToList,
|
|
1089
|
+
|
|
1090
|
+
// Dirty tracking
|
|
1091
|
+
takeSnapshot,
|
|
1092
|
+
checkDirty,
|
|
1093
|
+
isFieldDirty,
|
|
1094
|
+
|
|
1095
|
+
// Field management
|
|
1096
|
+
fields,
|
|
1097
|
+
generateFields,
|
|
1098
|
+
addField,
|
|
1099
|
+
removeField,
|
|
1100
|
+
excludeField,
|
|
1101
|
+
getFieldConfig,
|
|
1102
|
+
getFields,
|
|
1103
|
+
setFieldOrder,
|
|
1104
|
+
moveField,
|
|
1105
|
+
|
|
1106
|
+
// Validation
|
|
1107
|
+
errors,
|
|
1108
|
+
hasErrors,
|
|
1109
|
+
errorSummary,
|
|
1110
|
+
submitted,
|
|
1111
|
+
validate,
|
|
1112
|
+
validateField,
|
|
1113
|
+
clearErrors,
|
|
1114
|
+
clearFieldError,
|
|
1115
|
+
getFieldError,
|
|
1116
|
+
handleFieldBlur,
|
|
1117
|
+
|
|
1118
|
+
// Action management
|
|
1119
|
+
actions,
|
|
1120
|
+
addAction,
|
|
1121
|
+
removeAction,
|
|
1122
|
+
getActions,
|
|
1123
|
+
addSaveAction,
|
|
1124
|
+
addDeleteAction,
|
|
1125
|
+
addCancelAction,
|
|
1126
|
+
|
|
1127
|
+
// Permissions
|
|
1128
|
+
canSave,
|
|
1129
|
+
canDeleteRecord,
|
|
1130
|
+
|
|
1131
|
+
// Breadcrumb
|
|
1132
|
+
breadcrumb: breadcrumbItems,
|
|
1133
|
+
|
|
1134
|
+
// Guard dialog (for UnsavedChangesDialog - pass to PageLayout)
|
|
1135
|
+
guardDialog,
|
|
1136
|
+
|
|
1137
|
+
// Title helpers
|
|
1138
|
+
entityLabel,
|
|
1139
|
+
pageTitle,
|
|
1140
|
+
pageTitleParts,
|
|
1141
|
+
|
|
1142
|
+
// Utilities
|
|
1143
|
+
toast,
|
|
1144
|
+
confirm,
|
|
1145
|
+
router,
|
|
1146
|
+
route,
|
|
1147
|
+
|
|
1148
|
+
// FormPage integration
|
|
1149
|
+
props: formProps,
|
|
1150
|
+
events: formEvents
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return builderApi
|
|
1154
|
+
}
|