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.
Files changed (66) hide show
  1. package/README.md +153 -1
  2. package/package.json +15 -2
  3. package/src/components/BoolCell.vue +11 -6
  4. package/src/components/forms/FormField.vue +64 -6
  5. package/src/components/forms/FormPage.vue +276 -0
  6. package/src/components/index.js +11 -0
  7. package/src/components/layout/AppLayout.vue +18 -9
  8. package/src/components/layout/BaseLayout.vue +183 -0
  9. package/src/components/layout/DashboardLayout.vue +100 -0
  10. package/src/components/layout/FormLayout.vue +261 -0
  11. package/src/components/layout/ListLayout.vue +334 -0
  12. package/src/components/layout/PageHeader.vue +6 -9
  13. package/src/components/layout/PageNav.vue +15 -0
  14. package/src/components/layout/Zone.vue +165 -0
  15. package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
  16. package/src/components/layout/defaults/DefaultFooter.vue +56 -0
  17. package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
  18. package/src/components/layout/defaults/DefaultHeader.vue +69 -0
  19. package/src/components/layout/defaults/DefaultMenu.vue +197 -0
  20. package/src/components/layout/defaults/DefaultPagination.vue +79 -0
  21. package/src/components/layout/defaults/DefaultTable.vue +130 -0
  22. package/src/components/layout/defaults/DefaultToaster.vue +16 -0
  23. package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
  24. package/src/components/layout/defaults/index.js +17 -0
  25. package/src/composables/index.js +8 -6
  26. package/src/composables/useBreadcrumb.js +9 -5
  27. package/src/composables/useForm.js +135 -0
  28. package/src/composables/useFormPageBuilder.js +1154 -0
  29. package/src/composables/useHooks.js +53 -0
  30. package/src/composables/useLayoutResolver.js +260 -0
  31. package/src/composables/useListPageBuilder.js +336 -52
  32. package/src/composables/useNavContext.js +372 -0
  33. package/src/composables/useNavigation.js +38 -2
  34. package/src/composables/usePageTitle.js +59 -0
  35. package/src/composables/useSignals.js +49 -0
  36. package/src/composables/useZoneRegistry.js +162 -0
  37. package/src/core/bundles.js +406 -0
  38. package/src/core/decorator.js +322 -0
  39. package/src/core/extension.js +386 -0
  40. package/src/core/index.js +28 -0
  41. package/src/entity/EntityManager.js +314 -16
  42. package/src/entity/auth/AuthAdapter.js +125 -0
  43. package/src/entity/auth/PermissiveAdapter.js +64 -0
  44. package/src/entity/auth/index.js +11 -0
  45. package/src/entity/index.js +3 -0
  46. package/src/entity/storage/MockApiStorage.js +349 -0
  47. package/src/entity/storage/SdkStorage.js +478 -0
  48. package/src/entity/storage/index.js +2 -0
  49. package/src/hooks/HookRegistry.js +411 -0
  50. package/src/hooks/index.js +12 -0
  51. package/src/index.js +12 -0
  52. package/src/kernel/Kernel.js +141 -4
  53. package/src/kernel/SignalBus.js +180 -0
  54. package/src/kernel/index.js +7 -0
  55. package/src/module/moduleRegistry.js +124 -6
  56. package/src/orchestrator/Orchestrator.js +73 -1
  57. package/src/plugin.js +5 -0
  58. package/src/zones/ZoneRegistry.js +821 -0
  59. package/src/zones/index.js +16 -0
  60. package/src/zones/zones.js +189 -0
  61. package/src/composables/useEntityTitle.js +0 -121
  62. package/src/composables/useManager.js +0 -20
  63. package/src/composables/usePageBuilder.js +0 -334
  64. package/src/composables/useStatus.js +0 -146
  65. package/src/composables/useSubEditor.js +0 -165
  66. 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
+ }