@quazardous/qdadm 1.22.0 → 2.1.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.
@@ -1,544 +0,0 @@
1
- /**
2
- * useForm - CRUD form composable extending useBareForm
3
- *
4
- * Provides standardized form handling:
5
- * - Loading and saving states (via EntityManager)
6
- * - Dirty state tracking (via useBareForm)
7
- * - Unsaved changes guard (via useBareForm)
8
- * - Toast notifications
9
- * - Navigation helpers
10
- * - form:alter hook for extensibility
11
- *
12
- * Usage:
13
- * ```ts
14
- * // Minimal - reads config from EntityManager
15
- * const { form, loading, saving, dirty, isEdit, submit, cancel } = useForm({
16
- * entity: 'users',
17
- * getId: () => route.params.id
18
- * })
19
- *
20
- * // With overrides
21
- * const { form, ... } = useForm({
22
- * entity: 'users',
23
- * getId: () => route.params.id,
24
- * initialData: { name: '', email: '' }, // Override manager.getInitialData()
25
- * routePrefix: 'user', // Override manager.routePrefix
26
- * entityName: 'User' // Override manager.label
27
- * })
28
- * ```
29
- *
30
- * ## form:alter Hook
31
- *
32
- * The composable invokes `form:alter` and `{entity}:form:alter` hooks after
33
- * form initialization (load for edit, or initial data for create). This allows
34
- * modules to modify form configuration dynamically.
35
- */
36
- import { ref, computed, watch, onMounted, inject, provide, type Ref, type ComputedRef } from 'vue'
37
- import { useBareForm } from './useBareForm'
38
- import { useHooks } from './useHooks'
39
- import { useStackHydrator } from '../chain/useStackHydrator'
40
- import { deepClone } from '../utils/transformers'
41
- import type { GuardDialogState } from './useUnsavedChangesGuard'
42
- import type { BreadcrumbDisplayItem } from './useBreadcrumb'
43
-
44
- /**
45
- * Entity manager interface
46
- */
47
- interface EntityManager {
48
- idField: string
49
- label?: string
50
- labelField?: string
51
- routePrefix?: string
52
- fields?: FieldDefinition[]
53
- getInitialData: () => Record<string, unknown>
54
- getFormFields?: () => FieldDefinition[]
55
- getEntityLabel: (data: unknown) => string
56
- get: (id: string | number) => Promise<unknown>
57
- create: (data: unknown) => Promise<unknown>
58
- update: (id: string | number, data: unknown) => Promise<unknown>
59
- patch: (id: string | number, data: unknown) => Promise<unknown>
60
- delete: (id: string | number) => Promise<void>
61
- }
62
-
63
- /**
64
- * Orchestrator interface
65
- */
66
- interface Orchestrator {
67
- get: (entityName: string) => EntityManager
68
- toast: {
69
- success: (summary: string, detail?: string, emitter?: unknown) => void
70
- error: (summary: string, detail?: string, emitter?: unknown) => void
71
- warn: (summary: string, detail?: string, emitter?: unknown) => void
72
- info: (summary: string, detail?: string, emitter?: unknown) => void
73
- }
74
- }
75
-
76
- /**
77
- * Field definition interface
78
- */
79
- interface FieldDefinition {
80
- name: string
81
- type?: string
82
- label?: string
83
- computed?: boolean
84
- readonly?: boolean
85
- [key: string]: unknown
86
- }
87
-
88
- /**
89
- * Form alter config passed to hooks
90
- */
91
- export interface FormAlterConfig {
92
- entity: string
93
- fields: FieldDefinition[]
94
- isEdit: boolean
95
- entityId: string | number | null
96
- form: unknown
97
- manager: EntityManager
98
- }
99
-
100
- /**
101
- * Page title parts for PageHeader
102
- */
103
- interface PageTitleParts {
104
- action: string
105
- entityName: string | undefined
106
- entityLabel: string | null
107
- }
108
-
109
- /**
110
- * Axios-like error interface
111
- */
112
- interface AxiosError {
113
- response?: {
114
- data?: {
115
- detail?: string
116
- }
117
- }
118
- message?: string
119
- }
120
-
121
- /**
122
- * Options for useForm
123
- */
124
- export interface UseFormOptions {
125
- /** Entity name for EntityManager */
126
- entity: string
127
- /** Custom function to extract entity ID from route */
128
- getId?: (() => string | number | null) | null
129
- /** Transform loaded data before setting form */
130
- transformLoad?: (data: unknown) => unknown
131
- /** Transform form data before saving */
132
- transformSave?: (data: unknown) => unknown
133
- /** Callback on successful load */
134
- onLoadSuccess?: ((data: unknown) => Promise<void> | void) | null
135
- /** Callback on successful save */
136
- onSaveSuccess?: ((data: unknown, andClose: boolean) => Promise<void> | void) | null
137
- /** Enable unsaved changes guard (default: true) */
138
- enableGuard?: boolean
139
- /** Redirect to edit page after create (default: true) */
140
- redirectOnCreate?: boolean
141
- /** Custom dirty state getter */
142
- getDirtyState?: (() => Record<string, unknown>) | null
143
- /** Use PATCH instead of PUT for updates (default: false) */
144
- usePatch?: boolean
145
- /** Override manager.routePrefix */
146
- routePrefix?: string
147
- /** Override manager.label */
148
- entityName?: string
149
- /** Override manager.getInitialData() */
150
- initialData?: Record<string, unknown>
151
- }
152
-
153
- /**
154
- * Return type for useForm
155
- */
156
- export interface UseFormReturn {
157
- // Manager access
158
- manager: EntityManager
159
-
160
- // State
161
- form: Ref<unknown>
162
- loading: Ref<boolean>
163
- saving: Ref<boolean>
164
- dirty: Ref<boolean>
165
- dirtyFields: Ref<string[]>
166
- isEdit: ComputedRef<boolean>
167
- entityId: ComputedRef<string | number | null>
168
- originalData: Ref<unknown>
169
-
170
- // form:alter hook results
171
- alteredFields: Ref<FieldDefinition[]>
172
- hooksInvoked: Ref<boolean>
173
- invokeFormAlterHook: () => Promise<void>
174
-
175
- // Actions
176
- load: () => Promise<void>
177
- submit: (andClose?: boolean) => Promise<unknown>
178
- cancel: () => void
179
- remove: () => Promise<void>
180
- reset: () => void
181
- takeSnapshot: () => void
182
- checkDirty: () => void
183
- isFieldDirty: (fieldPath: string) => boolean
184
-
185
- // Breadcrumb (auto-generated from route)
186
- breadcrumb: ComputedRef<BreadcrumbDisplayItem[]>
187
-
188
- // Guard dialog (for UnsavedChangesDialog - pass to PageLayout)
189
- guardDialog: GuardDialogState | null
190
-
191
- // Title helpers
192
- entityLabel: ComputedRef<string>
193
- pageTitle: ComputedRef<string>
194
- pageTitleParts: ComputedRef<PageTitleParts>
195
- }
196
-
197
- /**
198
- * CRUD form composable
199
- *
200
- * @param options - Configuration options
201
- * @returns Form utilities and state
202
- */
203
- export function useForm(options: UseFormOptions): UseFormReturn {
204
- const {
205
- entity,
206
- getId = null,
207
- // Callbacks
208
- transformLoad = (data: unknown) => data,
209
- transformSave = (data: unknown) => data,
210
- onLoadSuccess = null,
211
- onSaveSuccess = null,
212
- // Options
213
- enableGuard = true,
214
- redirectOnCreate = true,
215
- getDirtyState = null,
216
- usePatch = false, // Use PATCH instead of PUT for updates
217
- } = options
218
-
219
- // Get EntityManager via orchestrator
220
- const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
221
- if (!orchestrator) {
222
- throw new Error(
223
- '[qdadm] Orchestrator not provided. Make sure to use createQdadm() with entityFactory.'
224
- )
225
- }
226
- const manager = orchestrator.get(entity)
227
-
228
- // Get HookRegistry for form:alter hook (optional, may not exist in tests)
229
- const hooks = useHooks()
230
-
231
- // Active stack for navigation context
232
- const hydrator = useStackHydrator()
233
-
234
- // Read config from manager with option overrides
235
- const routePrefix = options.routePrefix ?? manager.routePrefix
236
- const entityName = options.entityName ?? manager.label
237
- const initialData = options.initialData ?? manager.getInitialData()
238
-
239
- // Form-specific state
240
- const form = ref<unknown>(deepClone(initialData))
241
- const originalData = ref<unknown>(null)
242
-
243
- /**
244
- * Altered fields configuration after form:alter hook processing
245
- *
246
- * Contains the field definitions modified by registered hooks.
247
- * Modules can register form:alter hooks to modify field visibility,
248
- * add/remove fields, change labels, or modify validation rules.
249
- */
250
- const alteredFields = ref<FieldDefinition[]>([])
251
-
252
- /**
253
- * Whether form:alter hooks have been invoked
254
- */
255
- const hooksInvoked = ref(false)
256
-
257
- // Dirty state getter
258
- const dirtyStateGetter = getDirtyState || (() => ({ form: form.value }))
259
-
260
- // Use base form for common functionality
261
- const {
262
- // Dependencies
263
- router,
264
- toast,
265
- // State from useBareForm
266
- loading,
267
- saving,
268
- dirty,
269
- dirtyFields,
270
- isEdit,
271
- entityId,
272
- // Dirty tracking
273
- isFieldDirty,
274
- takeSnapshot,
275
- checkDirty,
276
- // Helpers
277
- cancel,
278
- // Breadcrumb
279
- breadcrumb,
280
- // Guard dialog for unsaved changes
281
- guardDialog,
282
- } = useBareForm({
283
- getState: dirtyStateGetter,
284
- routePrefix,
285
- guard: enableGuard,
286
- onGuardSave: async () => { await submit(false) },
287
- getId,
288
- })
289
-
290
- // Watch for changes
291
- watch(form, checkDirty, { deep: true })
292
-
293
- // ============ FORM:ALTER HOOK ============
294
-
295
- /**
296
- * Invoke form:alter hooks to allow modules to modify form configuration
297
- *
298
- * Builds a config snapshot from current state, passes it through the hook chain,
299
- * and stores the altered fields configuration.
300
- */
301
- async function invokeFormAlterHook(): Promise<void> {
302
- if (!hooks) return
303
-
304
- // Get fields from manager (if available)
305
- const managerFields =
306
- typeof manager.getFormFields === 'function'
307
- ? manager.getFormFields()
308
- : manager.fields || []
309
-
310
- // Build config snapshot
311
- const configSnapshot: FormAlterConfig = {
312
- entity,
313
- fields: deepClone(managerFields) as FieldDefinition[],
314
- isEdit: isEdit.value,
315
- entityId: entityId.value,
316
- form: form.value,
317
- manager,
318
- }
319
-
320
- // Invoke generic form:alter hook
321
- let alteredConfig = (await hooks.alter('form:alter', configSnapshot)) as FormAlterConfig
322
-
323
- // Invoke entity-specific hook: {entity}:form:alter
324
- const entityHookName = `${entity}:form:alter`
325
- if (hooks.hasHook(entityHookName)) {
326
- alteredConfig = (await hooks.alter(entityHookName, alteredConfig)) as FormAlterConfig
327
- }
328
-
329
- // Store altered fields for consumption by FormPage
330
- alteredFields.value = alteredConfig.fields || []
331
- hooksInvoked.value = true
332
- }
333
-
334
- // Load entity
335
- async function load(): Promise<void> {
336
- if (!isEdit.value) {
337
- form.value = deepClone(initialData)
338
- takeSnapshot()
339
- // Invoke form:alter hooks for create mode
340
- await invokeFormAlterHook()
341
- return
342
- }
343
-
344
- loading.value = true
345
- try {
346
- const responseData = await manager.get(entityId.value!)
347
- const data = transformLoad(responseData)
348
- form.value = data
349
- originalData.value = deepClone(data)
350
- takeSnapshot()
351
-
352
- // Update active stack
353
- hydrator.setCurrentData(data)
354
-
355
- // Invoke form:alter hooks after data is loaded
356
- await invokeFormAlterHook()
357
-
358
- if (onLoadSuccess) {
359
- await onLoadSuccess(data)
360
- }
361
- } catch (error) {
362
- console.error(`Failed to load ${entityName}:`, error)
363
- const axiosError = error as AxiosError
364
- toast.add({
365
- severity: 'error',
366
- summary: 'Error',
367
- detail: axiosError.response?.data?.detail || `Failed to load ${entityName}`,
368
- })
369
- } finally {
370
- loading.value = false
371
- }
372
- }
373
-
374
- // Submit form
375
- async function submit(andClose = true): Promise<unknown> {
376
- saving.value = true
377
- try {
378
- const payload = transformSave(deepClone(form.value))
379
-
380
- let responseData: unknown
381
- if (isEdit.value) {
382
- if (usePatch) {
383
- responseData = await manager.patch(entityId.value!, payload)
384
- } else {
385
- responseData = await manager.update(entityId.value!, payload)
386
- }
387
- } else {
388
- responseData = await manager.create(payload)
389
- }
390
-
391
- toast.add({
392
- severity: 'success',
393
- summary: 'Success',
394
- detail: `${entityName} ${isEdit.value ? 'updated' : 'created'} successfully`,
395
- })
396
-
397
- // Update original data and snapshot
398
- const savedData = transformLoad(responseData)
399
- form.value = savedData
400
- originalData.value = deepClone(savedData)
401
- takeSnapshot()
402
-
403
- if (onSaveSuccess) {
404
- await onSaveSuccess(responseData, andClose)
405
- }
406
-
407
- if (andClose) {
408
- router.push({ name: routePrefix })
409
- } else if (!isEdit.value && redirectOnCreate) {
410
- // Redirect to edit mode after create
411
- const newId = (responseData as Record<string, unknown>)[manager.idField]
412
- router.replace({ name: `${routePrefix}-edit`, params: { id: newId as string } })
413
- }
414
-
415
- return responseData
416
- } catch (error) {
417
- console.error(`Failed to save ${entityName}:`, error)
418
- const axiosError = error as AxiosError
419
- toast.add({
420
- severity: 'error',
421
- summary: 'Error',
422
- detail: axiosError.response?.data?.detail || `Failed to save ${entityName}`,
423
- })
424
- throw error
425
- } finally {
426
- saving.value = false
427
- }
428
- }
429
-
430
- // Delete entity
431
- async function remove(): Promise<void> {
432
- if (!isEdit.value) return
433
-
434
- try {
435
- await manager.delete(entityId.value!)
436
- toast.add({
437
- severity: 'success',
438
- summary: 'Success',
439
- detail: `${entityName} deleted successfully`,
440
- })
441
- router.push({ name: routePrefix })
442
- } catch (error) {
443
- console.error(`Failed to delete ${entityName}:`, error)
444
- const axiosError = error as AxiosError
445
- toast.add({
446
- severity: 'error',
447
- summary: 'Error',
448
- detail: axiosError.response?.data?.detail || `Failed to delete ${entityName}`,
449
- })
450
- }
451
- }
452
-
453
- // Reset form to original data
454
- function reset(): void {
455
- if (originalData.value) {
456
- form.value = deepClone(originalData.value)
457
- } else {
458
- form.value = deepClone(initialData)
459
- }
460
- takeSnapshot()
461
- }
462
-
463
- // Load on mount if editing
464
- onMounted(() => {
465
- load()
466
- })
467
-
468
- // ============ COMPUTED TITLE ============
469
-
470
- /**
471
- * Entity display label (e.g., "David Berlioz" for an agent)
472
- * Uses manager.getEntityLabel() with labelField config
473
- */
474
- const entityLabel = computed(() => {
475
- return manager.getEntityLabel(form.value)
476
- })
477
-
478
- /**
479
- * Auto-generated page title
480
- * - Edit mode: "Edit Agent: David Berlioz"
481
- * - Create mode: "Create Agent"
482
- */
483
- const pageTitle = computed(() => {
484
- if (isEdit.value) {
485
- const label = entityLabel.value
486
- return label ? `Edit ${entityName}: ${label}` : `Edit ${entityName}`
487
- }
488
- return `Create ${entityName}`
489
- })
490
-
491
- /**
492
- * Structured page title for decorated rendering
493
- * Returns { action, entityName, entityLabel } for custom styling
494
- */
495
- const pageTitleParts = computed<PageTitleParts>(() => ({
496
- action: isEdit.value ? 'Edit' : 'Create',
497
- entityName,
498
- entityLabel: isEdit.value ? entityLabel.value : null,
499
- }))
500
-
501
- // Provide title parts for automatic PageHeader consumption
502
- provide('qdadmPageTitleParts', pageTitleParts)
503
-
504
- return {
505
- // Manager access
506
- manager,
507
-
508
- // State
509
- form,
510
- loading,
511
- saving,
512
- dirty,
513
- dirtyFields,
514
- isEdit,
515
- entityId,
516
- originalData,
517
-
518
- // form:alter hook results
519
- alteredFields,
520
- hooksInvoked,
521
- invokeFormAlterHook,
522
-
523
- // Actions
524
- load,
525
- submit,
526
- cancel,
527
- remove,
528
- reset,
529
- takeSnapshot,
530
- checkDirty,
531
- isFieldDirty,
532
-
533
- // Breadcrumb (auto-generated from route)
534
- breadcrumb,
535
-
536
- // Guard dialog (for UnsavedChangesDialog - pass to PageLayout)
537
- guardDialog,
538
-
539
- // Title helpers
540
- entityLabel,
541
- pageTitle,
542
- pageTitleParts,
543
- }
544
- }