@quazardous/qdadm 1.19.3
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/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +101 -0
- package/src/assets/logo.svg +6 -0
- package/src/auth/SessionAuthAdapter.ts +459 -0
- package/src/auth/index.ts +15 -0
- package/src/chain/ActiveStack.ts +154 -0
- package/src/chain/StackHydrator.ts +292 -0
- package/src/chain/index.ts +25 -0
- package/src/chain/useActiveStack.ts +120 -0
- package/src/chain/useStackHydrator.ts +155 -0
- package/src/components/BoolCell.vue +34 -0
- package/src/components/InfoBanner.vue +73 -0
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/SeverityTag.vue +86 -0
- package/src/components/dialogs/BulkStatusDialog.vue +60 -0
- package/src/components/dialogs/MultiStepDialog.vue +331 -0
- package/src/components/dialogs/SimpleDialog.vue +122 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +80 -0
- package/src/components/display/BannerZone.vue +65 -0
- package/src/components/display/CardsGrid.vue +72 -0
- package/src/components/display/CopyableId.vue +84 -0
- package/src/components/display/EmptyState.vue +62 -0
- package/src/components/display/IntensityBar.vue +190 -0
- package/src/components/display/RichCardsGrid.vue +101 -0
- package/src/components/edit/FormActions.vue +78 -0
- package/src/components/edit/FormField.vue +82 -0
- package/src/components/edit/FormInput.vue +158 -0
- package/src/components/edit/FormPage.vue +260 -0
- package/src/components/edit/FormTab.vue +217 -0
- package/src/components/edit/FormTabs.vue +120 -0
- package/src/components/edit/LookupField.vue +406 -0
- package/src/components/edit/LookupPickerDialog.vue +230 -0
- package/src/components/editors/JsonEditorFoldable.vue +470 -0
- package/src/components/editors/JsonStructuredField.vue +264 -0
- package/src/components/editors/JsonViewer.vue +51 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +259 -0
- package/src/components/editors/PermissionEditor.vue +558 -0
- package/src/components/editors/ScopeEditor.vue +372 -0
- package/src/components/editors/VanillaJsonEditor.vue +183 -0
- package/src/components/index.ts +83 -0
- package/src/components/item/FieldGroups.vue +392 -0
- package/src/components/layout/AppLayout.vue +1032 -0
- package/src/components/layout/BaseLayout.vue +190 -0
- package/src/components/layout/Breadcrumb.vue +55 -0
- package/src/components/layout/DashboardLayout.vue +104 -0
- package/src/components/layout/FormLayout.vue +256 -0
- package/src/components/layout/ListLayout.vue +278 -0
- package/src/components/layout/PageHeader.vue +156 -0
- package/src/components/layout/PageLayout.vue +126 -0
- package/src/components/layout/PageNav.vue +192 -0
- package/src/components/layout/SidebarBox.vue +87 -0
- package/src/components/layout/Zone.vue +182 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +149 -0
- package/src/components/layout/defaults/DefaultFooter.vue +89 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +67 -0
- package/src/components/layout/defaults/DefaultHeader.vue +78 -0
- package/src/components/layout/defaults/DefaultMenu.vue +188 -0
- package/src/components/layout/defaults/DefaultPagination.vue +75 -0
- package/src/components/layout/defaults/DefaultTable.vue +137 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +111 -0
- package/src/components/layout/defaults/index.ts +16 -0
- package/src/components/lists/ActionButtons.vue +49 -0
- package/src/components/lists/ActionColumn.vue +40 -0
- package/src/components/lists/FilterBar.vue +54 -0
- package/src/components/lists/ListPage.vue +467 -0
- package/src/components/pages/LoginPage.vue +334 -0
- package/src/components/pages/NotFoundPage.vue +98 -0
- package/src/components/show/ShowDisplay.vue +343 -0
- package/src/components/show/ShowField.vue +67 -0
- package/src/components/show/ShowPage.vue +431 -0
- package/src/composables/index.ts +155 -0
- package/src/composables/useApp.ts +67 -0
- package/src/composables/useAuth.ts +98 -0
- package/src/composables/useBareForm.ts +319 -0
- package/src/composables/useBreadcrumb.ts +252 -0
- package/src/composables/useChildPage.ts +113 -0
- package/src/composables/useCurrentEntity.ts +60 -0
- package/src/composables/useDeferred.ts +94 -0
- package/src/composables/useDirtyState.ts +138 -0
- package/src/composables/useEntityItemFormPage.ts +942 -0
- package/src/composables/useEntityItemFormPage.types.ts +451 -0
- package/src/composables/useEntityItemPage.ts +438 -0
- package/src/composables/useEntityItemShowPage.ts +722 -0
- package/src/composables/useFieldManager.ts +692 -0
- package/src/composables/useForm.ts +544 -0
- package/src/composables/useGuardStore.ts +53 -0
- package/src/composables/useHooks.ts +56 -0
- package/src/composables/useInfoBanner.ts +217 -0
- package/src/composables/useJsonSyntax.ts +122 -0
- package/src/composables/useLayoutResolver.ts +329 -0
- package/src/composables/useListPage.ts +1535 -0
- package/src/composables/useListPage.types.ts +437 -0
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavContext.ts +439 -0
- package/src/composables/useNavigation.ts +216 -0
- package/src/composables/useOptionsLookup.ts +323 -0
- package/src/composables/usePageTitle.ts +84 -0
- package/src/composables/useSSEBridge.ts +142 -0
- package/src/composables/useSemanticBreadcrumb.ts +222 -0
- package/src/composables/useSidebarState.ts +24 -0
- package/src/composables/useSignals.ts +52 -0
- package/src/composables/useUnsavedChangesGuard.ts +155 -0
- package/src/composables/useUserImpersonator.ts +317 -0
- package/src/composables/useZoneRegistry.ts +252 -0
- package/src/core/bundles.ts +558 -0
- package/src/core/decorator.ts +420 -0
- package/src/core/extension.ts +470 -0
- package/src/core/index.ts +59 -0
- package/src/deferred/DeferredRegistry.ts +353 -0
- package/src/deferred/index.ts +15 -0
- package/src/editors/index.ts +12 -0
- package/src/entity/EntityManager.cache.ts +530 -0
- package/src/entity/EntityManager.crud.ts +615 -0
- package/src/entity/EntityManager.interface.ts +78 -0
- package/src/entity/EntityManager.query.ts +221 -0
- package/src/entity/EntityManager.relations.ts +151 -0
- package/src/entity/EntityManager.ts +944 -0
- package/src/entity/EntityManager.types.ts +288 -0
- package/src/entity/auth/CompositeAuthAdapter.ts +194 -0
- package/src/entity/auth/EntityAuthAdapter.ts +119 -0
- package/src/entity/auth/PermissiveAdapter.ts +43 -0
- package/src/entity/auth/RoleHierarchy.ts +128 -0
- package/src/entity/auth/SecurityChecker.ts +202 -0
- package/src/entity/auth/factory.ts +203 -0
- package/src/entity/auth/index.ts +47 -0
- package/src/entity/factory.test.js +189 -0
- package/src/entity/factory.ts +146 -0
- package/src/entity/index.ts +49 -0
- package/src/entity/storage/ApiStorage.ts +228 -0
- package/src/entity/storage/IStorage.ts +100 -0
- package/src/entity/storage/LocalStorage.ts +200 -0
- package/src/entity/storage/MemoryStorage.ts +184 -0
- package/src/entity/storage/MockApiStorage.ts +318 -0
- package/src/entity/storage/SdkStorage.ts +377 -0
- package/src/entity/storage/factory.test.js +159 -0
- package/src/entity/storage/factory.ts +209 -0
- package/src/entity/storage/index.ts +75 -0
- package/src/gen/FieldMapper.ts +122 -0
- package/src/gen/StorageProfileFactory.ts +93 -0
- package/src/gen/connectors/BaseConnector.ts +150 -0
- package/src/gen/connectors/ManualConnector.test.js +499 -0
- package/src/gen/connectors/ManualConnector.ts +414 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +836 -0
- package/src/gen/connectors/OpenAPIConnector.ts +693 -0
- package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
- package/src/gen/connectors/index.ts +27 -0
- package/src/gen/createManagers.ts +245 -0
- package/src/gen/decorators.ts +134 -0
- package/src/gen/generateManagers.test.js +540 -0
- package/src/gen/generateManagers.ts +398 -0
- package/src/gen/index.ts +66 -0
- package/src/gen/schema.ts +248 -0
- package/src/gen/vite-plugin.ts +114 -0
- package/src/generated/managers/testManager.js +45 -0
- package/src/generated/managers/testManager.ts +46 -0
- package/src/hooks/HookRegistry.ts +16 -0
- package/src/hooks/index.ts +15 -0
- package/src/i18n/I18n.ts +344 -0
- package/src/i18n/IncrementalDomainProvider.ts +153 -0
- package/src/i18n/InlineTranslationProvider.ts +4 -0
- package/src/i18n/LazyTranslationProvider.ts +102 -0
- package/src/i18n/MessagesRegistry.ts +4 -0
- package/src/i18n/Resolver.ts +4 -0
- package/src/i18n/__tests__/I18n.test.ts +169 -0
- package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
- package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
- package/src/i18n/__tests__/Resolver.test.ts +271 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
- package/src/i18n/defaults/core.en.yml +55 -0
- package/src/i18n/defaults/core.fr.yml +55 -0
- package/src/i18n/index.ts +55 -0
- package/src/i18n/loaders/raw-modules.d.ts +15 -0
- package/src/i18n/loaders/yaml.ts +35 -0
- package/src/i18n/strategies.ts +4 -0
- package/src/i18n/types.ts +34 -0
- package/src/i18n/useI18n.ts +34 -0
- package/src/index.ts +453 -0
- package/src/kernel/EventRouter.ts +23 -0
- package/src/kernel/Kernel.api.ts +79 -0
- package/src/kernel/Kernel.auth.ts +120 -0
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +170 -0
- package/src/kernel/Kernel.registries.ts +237 -0
- package/src/kernel/Kernel.routing.ts +334 -0
- package/src/kernel/Kernel.ts +460 -0
- package/src/kernel/Kernel.types.ts +263 -0
- package/src/kernel/Kernel.vue.ts +304 -0
- package/src/kernel/KernelContext.entities.ts +80 -0
- package/src/kernel/KernelContext.events.ts +57 -0
- package/src/kernel/KernelContext.i18n.ts +37 -0
- package/src/kernel/KernelContext.permissions.ts +38 -0
- package/src/kernel/KernelContext.routing.ts +280 -0
- package/src/kernel/KernelContext.ts +252 -0
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/Module.ts +169 -0
- package/src/kernel/ModuleLoader.ts +645 -0
- package/src/kernel/SSEBridge.ts +11 -0
- package/src/kernel/SignalBus.ts +48 -0
- package/src/kernel/index.ts +56 -0
- package/src/module/index.ts +33 -0
- package/src/module/moduleRegistry.ts +492 -0
- package/src/modules/debug/AuthCollector.ts +501 -0
- package/src/modules/debug/Collector.ts +20 -0
- package/src/modules/debug/DebugBridge.ts +14 -0
- package/src/modules/debug/DebugModule.ts +220 -0
- package/src/modules/debug/EntitiesCollector.ts +839 -0
- package/src/modules/debug/ErrorCollector.ts +4 -0
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +7 -0
- package/src/modules/debug/RouterCollector.ts +449 -0
- package/src/modules/debug/SignalCollector.ts +4 -0
- package/src/modules/debug/ToastCollector.ts +4 -0
- package/src/modules/debug/ZonesCollector.ts +447 -0
- package/src/modules/debug/components/DebugBar.vue +31 -0
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +9 -0
- package/src/modules/debug/components/panels/AuthPanel.vue +267 -0
- package/src/modules/debug/components/panels/EntitiesPanel.vue +572 -0
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +440 -0
- package/src/modules/debug/components/panels/ZonesPanel.vue +133 -0
- package/src/modules/debug/components/panels/index.ts +16 -0
- package/src/modules/debug/index.ts +58 -0
- package/src/modules/debug/styles.scss +2671 -0
- package/src/notifications/NotificationBadge.vue +35 -0
- package/src/notifications/NotificationListener.vue +71 -0
- package/src/notifications/NotificationModule.ts +76 -0
- package/src/notifications/NotificationPanel.vue +478 -0
- package/src/notifications/NotificationStore.ts +288 -0
- package/src/notifications/index.ts +42 -0
- package/src/notifications/styles.scss +32 -0
- package/src/orchestrator/Orchestrator.ts +427 -0
- package/src/orchestrator/index.ts +8 -0
- package/src/orchestrator/useOrchestrator.ts +80 -0
- package/src/plugin.ts +251 -0
- package/src/query/FilterQuery.ts +306 -0
- package/src/query/QueryExecutor.ts +341 -0
- package/src/query/index.ts +16 -0
- package/src/security/EntityRolesProvider.ts +385 -0
- package/src/security/PermissionMatcher.ts +152 -0
- package/src/security/PermissionRegistry.ts +273 -0
- package/src/security/PersistableRolesProvider.ts +584 -0
- package/src/security/RolesManager.ts +136 -0
- package/src/security/RolesProvider.ts +167 -0
- package/src/security/RolesProviderStorage.ts +211 -0
- package/src/security/SecurityModule.ts +94 -0
- package/src/security/StaticRolesProvider.ts +117 -0
- package/src/security/UsersManager.ts +163 -0
- package/src/security/index.ts +77 -0
- package/src/security/pages/RoleForm.vue +276 -0
- package/src/security/pages/RoleList.vue +123 -0
- package/src/styles/_alerts.scss +95 -0
- package/src/styles/_buttons.scss +391 -0
- package/src/styles/_cards.scss +241 -0
- package/src/styles/_code.scss +78 -0
- package/src/styles/_dialogs.scss +55 -0
- package/src/styles/_filter-bar.scss +70 -0
- package/src/styles/_forms.scss +434 -0
- package/src/styles/_helpers.scss +153 -0
- package/src/styles/_lists.scss +225 -0
- package/src/styles/_main.scss +356 -0
- package/src/styles/_markdown.scss +88 -0
- package/src/styles/_responsive.scss +255 -0
- package/src/styles/_show-pages.scss +186 -0
- package/src/styles/_sidebar.scss +380 -0
- package/src/styles/_states.scss +115 -0
- package/src/styles/_stats.scss +47 -0
- package/src/styles/_variables.scss +224 -0
- package/src/styles/index.scss +38 -0
- package/src/styles/theme/_components.scss +331 -0
- package/src/styles/theme/_index.scss +10 -0
- package/src/styles/theme/_tokens.scss +147 -0
- package/src/styles/theme/_utilities.scss +175 -0
- package/src/toast/ToastBridgeModule.ts +68 -0
- package/src/toast/ToastListener.vue +55 -0
- package/src/toast/index.ts +26 -0
- package/src/toast/useSignalToast.ts +154 -0
- package/src/types/index.ts +285 -0
- package/src/types/pluralize.d.ts +19 -0
- package/src/types/quarkernel.d.ts +50 -0
- package/src/utils/formatters.ts +174 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/transformers.ts +131 -0
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +50 -0
- package/src/zones/ZoneRegistry.ts +934 -0
- package/src/zones/index.ts +29 -0
- package/src/zones/zones.ts +191 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEntityItemFormPage - 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
|
+
* ```ts
|
|
16
|
+
* const form = useEntityItemFormPage({ entity: 'books' })
|
|
17
|
+
* form.addSaveAction()
|
|
18
|
+
* form.addDeleteAction()
|
|
19
|
+
*
|
|
20
|
+
* <FormPage v-bind="form.props.value" v-on="form.events">
|
|
21
|
+
* <template #fields>
|
|
22
|
+
* <FormField v-model="form.data.title" name="title" />
|
|
23
|
+
* </template>
|
|
24
|
+
* </FormPage>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { ref, computed, watch, onMounted, onUnmounted, provide, type Ref } from 'vue'
|
|
28
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
29
|
+
import { useConfirm } from 'primevue/useconfirm'
|
|
30
|
+
import { useDirtyState } from './useDirtyState'
|
|
31
|
+
import { useUnsavedChangesGuard, type GuardDialogState } from './useUnsavedChangesGuard'
|
|
32
|
+
import { useBreadcrumb } from './useBreadcrumb'
|
|
33
|
+
import {
|
|
34
|
+
useEntityItemPage,
|
|
35
|
+
type CreateContext,
|
|
36
|
+
} from './useEntityItemPage'
|
|
37
|
+
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
38
|
+
import { deepClone } from '../utils/transformers'
|
|
39
|
+
import { getSiblingRoutes } from '../module/moduleRegistry'
|
|
40
|
+
import {
|
|
41
|
+
useFieldManager,
|
|
42
|
+
} from './useFieldManager'
|
|
43
|
+
|
|
44
|
+
// Import types from dedicated types file
|
|
45
|
+
import type {
|
|
46
|
+
Orchestrator,
|
|
47
|
+
ToastHelper,
|
|
48
|
+
ConfirmService,
|
|
49
|
+
AxiosError,
|
|
50
|
+
ErrorSummaryItem,
|
|
51
|
+
PageTitleParts,
|
|
52
|
+
} from './useEntityItemFormPage.types'
|
|
53
|
+
import { TYPE_MAPPINGS, TYPE_VALIDATORS, snakeCaseToTitle, isEmpty } from './useEntityItemFormPage.types'
|
|
54
|
+
|
|
55
|
+
// Re-export public types
|
|
56
|
+
export type {
|
|
57
|
+
FieldDefinition,
|
|
58
|
+
ResolvedFieldConfig,
|
|
59
|
+
ActionConfig,
|
|
60
|
+
ResolvedAction,
|
|
61
|
+
FormPageProps,
|
|
62
|
+
FormPageEvents,
|
|
63
|
+
UseEntityItemFormPageOptions,
|
|
64
|
+
UseEntityItemFormPageReturn,
|
|
65
|
+
GenerateFieldsOptions,
|
|
66
|
+
AddFieldOptions,
|
|
67
|
+
AddSaveActionOptions,
|
|
68
|
+
AddDeleteActionOptions,
|
|
69
|
+
AddCancelActionOptions,
|
|
70
|
+
} from './useEntityItemFormPage.types'
|
|
71
|
+
|
|
72
|
+
// Import concrete types needed in the function body
|
|
73
|
+
import type {
|
|
74
|
+
FieldDefinition,
|
|
75
|
+
ResolvedFieldConfig,
|
|
76
|
+
ActionConfig,
|
|
77
|
+
ResolvedAction,
|
|
78
|
+
FormPageProps,
|
|
79
|
+
FormPageEvents,
|
|
80
|
+
UseEntityItemFormPageOptions,
|
|
81
|
+
UseEntityItemFormPageReturn,
|
|
82
|
+
GenerateFieldsOptions,
|
|
83
|
+
AddSaveActionOptions,
|
|
84
|
+
AddDeleteActionOptions,
|
|
85
|
+
AddCancelActionOptions,
|
|
86
|
+
} from './useEntityItemFormPage.types'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Unified procedural builder for CRUD form pages
|
|
91
|
+
*
|
|
92
|
+
* @param config - Configuration options
|
|
93
|
+
* @returns Form builder API
|
|
94
|
+
*/
|
|
95
|
+
export function useEntityItemFormPage<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
96
|
+
config: UseEntityItemFormPageOptions<T>
|
|
97
|
+
): UseEntityItemFormPageReturn<T> {
|
|
98
|
+
const {
|
|
99
|
+
entity,
|
|
100
|
+
// Mode detection
|
|
101
|
+
getId = null,
|
|
102
|
+
createRouteSuffix = 'create',
|
|
103
|
+
editRouteSuffix: _editRouteSuffix = 'edit', // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
104
|
+
// Form options
|
|
105
|
+
loadOnMount = true,
|
|
106
|
+
enableGuard = true,
|
|
107
|
+
usePatch = false,
|
|
108
|
+
// Hooks for custom behavior
|
|
109
|
+
transformLoad = (data: unknown) => data,
|
|
110
|
+
transformSave = (data: unknown) => data,
|
|
111
|
+
onLoadSuccess = null,
|
|
112
|
+
onSaveSuccess = null,
|
|
113
|
+
onDeleteSuccess = null,
|
|
114
|
+
// Validation options
|
|
115
|
+
validateOnBlur = true,
|
|
116
|
+
validateOnSubmit = true,
|
|
117
|
+
showErrorSummary = false,
|
|
118
|
+
// Field generation
|
|
119
|
+
generateFormFields = true,
|
|
120
|
+
} = config
|
|
121
|
+
|
|
122
|
+
const router = useRouter()
|
|
123
|
+
const route = useRoute()
|
|
124
|
+
const confirm = useConfirm() as ConfirmService
|
|
125
|
+
|
|
126
|
+
// Use useEntityItemPage for common infrastructure
|
|
127
|
+
const itemPage = useEntityItemPage({
|
|
128
|
+
entity,
|
|
129
|
+
loadOnMount: false, // Form controls its own loading
|
|
130
|
+
getId,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
manager,
|
|
135
|
+
orchestrator,
|
|
136
|
+
entityId,
|
|
137
|
+
getInitialDataWithParent,
|
|
138
|
+
parentConfig,
|
|
139
|
+
parentId,
|
|
140
|
+
parentData,
|
|
141
|
+
parentChain,
|
|
142
|
+
getChainDepth,
|
|
143
|
+
hydrator,
|
|
144
|
+
} = itemPage
|
|
145
|
+
|
|
146
|
+
// Toast helper - wraps orchestrator.toast for legacy compatibility
|
|
147
|
+
const toast: ToastHelper = {
|
|
148
|
+
add({ severity, summary, detail, emitter }) {
|
|
149
|
+
;(orchestrator as Orchestrator)?.toast?.[severity]?.(summary, detail, emitter)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read config from manager with option overrides
|
|
154
|
+
const entityName = config.entityName ?? manager.label
|
|
155
|
+
const routePrefix = config.routePrefix ?? manager.routePrefix
|
|
156
|
+
|
|
157
|
+
// Initial data: merge user-provided with auto-populated parent foreignKey
|
|
158
|
+
const baseInitialData = getInitialDataWithParent()
|
|
159
|
+
const initialData: Record<string, unknown> = config.initialData
|
|
160
|
+
? { ...baseInitialData, ...config.initialData }
|
|
161
|
+
: baseInitialData
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Detect form mode: 'create' or 'edit'
|
|
165
|
+
*/
|
|
166
|
+
const mode = computed<'create' | 'edit'>(() => {
|
|
167
|
+
const routeName = (route.name as string) || ''
|
|
168
|
+
if (routeName.endsWith(createRouteSuffix) || routeName.endsWith('-new')) {
|
|
169
|
+
return 'create'
|
|
170
|
+
}
|
|
171
|
+
if (entityId.value) {
|
|
172
|
+
return 'edit'
|
|
173
|
+
}
|
|
174
|
+
return 'create'
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const isEdit = computed(() => mode.value === 'edit')
|
|
178
|
+
const isCreate = computed(() => mode.value === 'create')
|
|
179
|
+
|
|
180
|
+
// ============ STATE ============
|
|
181
|
+
|
|
182
|
+
const data = ref(deepClone(initialData)) as Ref<T>
|
|
183
|
+
const originalData = ref<unknown>(null)
|
|
184
|
+
const loading = ref(false)
|
|
185
|
+
const saving = ref(false)
|
|
186
|
+
|
|
187
|
+
// Dirty state getter
|
|
188
|
+
const dirtyStateGetter = config.getDirtyState || (() => ({ form: data.value }))
|
|
189
|
+
|
|
190
|
+
// Dirty state tracking
|
|
191
|
+
const { dirty, dirtyFields, isFieldDirty, takeSnapshot, checkDirty } =
|
|
192
|
+
useDirtyState(dirtyStateGetter)
|
|
193
|
+
|
|
194
|
+
// Provide isFieldDirty and dirtyFields for child components (FormField)
|
|
195
|
+
provide('isFieldDirty', isFieldDirty)
|
|
196
|
+
provide('dirtyFields', dirtyFields)
|
|
197
|
+
|
|
198
|
+
// Watch for changes to update dirty state
|
|
199
|
+
watch(data, checkDirty, { deep: true })
|
|
200
|
+
|
|
201
|
+
// ============ VALIDATION STATE ============
|
|
202
|
+
|
|
203
|
+
const errors = ref<Record<string, string>>({})
|
|
204
|
+
const submitted = ref(false)
|
|
205
|
+
const hasErrors = computed(() => Object.keys(errors.value).length > 0)
|
|
206
|
+
|
|
207
|
+
const errorSummary = computed<ErrorSummaryItem[]>(() => {
|
|
208
|
+
return Object.entries(errors.value).map(([field, message]) => {
|
|
209
|
+
const fieldConfig = fieldsMap.value.get(field)
|
|
210
|
+
const label = fieldConfig?.label || snakeCaseToTitle(field)
|
|
211
|
+
return { field, label, message }
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// Provide validation state for child components (FormField)
|
|
216
|
+
provide('getFieldError', (name: string) => errors.value[name] || null)
|
|
217
|
+
provide('formSubmitted', submitted)
|
|
218
|
+
|
|
219
|
+
// ============ UNSAVED CHANGES GUARD ============
|
|
220
|
+
|
|
221
|
+
let guardDialog: GuardDialogState | null = null
|
|
222
|
+
if (enableGuard) {
|
|
223
|
+
const { guardDialog: gd } = useUnsavedChangesGuard(dirty, {
|
|
224
|
+
onSave: async () => { await submit(false) },
|
|
225
|
+
})
|
|
226
|
+
guardDialog = gd
|
|
227
|
+
registerGuardDialog(guardDialog)
|
|
228
|
+
onUnmounted(() => unregisterGuardDialog(guardDialog!))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============ BREADCRUMB ============
|
|
232
|
+
|
|
233
|
+
const { breadcrumbItems } = useBreadcrumb({ entity: data })
|
|
234
|
+
|
|
235
|
+
// ============ LOADING ============
|
|
236
|
+
|
|
237
|
+
async function load(): Promise<void> {
|
|
238
|
+
if (!isEdit.value) {
|
|
239
|
+
data.value = deepClone(initialData) as T
|
|
240
|
+
takeSnapshot()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
loading.value = true
|
|
245
|
+
try {
|
|
246
|
+
const responseData = await manager.get(entityId.value!)
|
|
247
|
+
const transformed = transformLoad(responseData)
|
|
248
|
+
data.value = transformed as T
|
|
249
|
+
originalData.value = deepClone(transformed)
|
|
250
|
+
takeSnapshot()
|
|
251
|
+
|
|
252
|
+
// Update active stack
|
|
253
|
+
hydrator.setCurrentData(transformed)
|
|
254
|
+
|
|
255
|
+
if (onLoadSuccess) {
|
|
256
|
+
await onLoadSuccess(transformed as T)
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const axiosError = error as AxiosError
|
|
260
|
+
toast.add({
|
|
261
|
+
severity: 'error',
|
|
262
|
+
summary: 'Error',
|
|
263
|
+
detail: axiosError.response?.data?.detail || `Failed to load ${entityName}`,
|
|
264
|
+
life: 5000,
|
|
265
|
+
})
|
|
266
|
+
} finally {
|
|
267
|
+
loading.value = false
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ============ SUBMIT ============
|
|
272
|
+
|
|
273
|
+
async function submit(andClose = true): Promise<unknown> {
|
|
274
|
+
// Mark as submitted (shows all errors in UI)
|
|
275
|
+
submitted.value = true
|
|
276
|
+
|
|
277
|
+
// Validate before submit if enabled
|
|
278
|
+
if (validateOnSubmit && !validate()) {
|
|
279
|
+
toast.add({
|
|
280
|
+
severity: 'warn',
|
|
281
|
+
summary: 'Validation Error',
|
|
282
|
+
detail: 'Please fix the errors before saving',
|
|
283
|
+
life: 3000,
|
|
284
|
+
})
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
saving.value = true
|
|
289
|
+
try {
|
|
290
|
+
const payload = transformSave(deepClone(data.value))
|
|
291
|
+
|
|
292
|
+
let responseData: unknown
|
|
293
|
+
if (isEdit.value) {
|
|
294
|
+
if (usePatch) {
|
|
295
|
+
responseData = await manager.patch(entityId.value!, payload)
|
|
296
|
+
} else {
|
|
297
|
+
responseData = await manager.update(entityId.value!, payload)
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Build context with parent chain for multi-storage routing
|
|
301
|
+
const context: CreateContext | undefined =
|
|
302
|
+
parentConfig.value && parentId.value
|
|
303
|
+
? { parentChain: [{ entity: parentConfig.value.entity, id: parentId.value }] }
|
|
304
|
+
: undefined
|
|
305
|
+
responseData = await manager.create(payload, context)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
toast.add({
|
|
309
|
+
severity: 'success',
|
|
310
|
+
summary: 'Success',
|
|
311
|
+
detail: `${entityName} ${isEdit.value ? 'updated' : 'created'} successfully`,
|
|
312
|
+
life: 3000,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Update data and snapshot
|
|
316
|
+
const savedData = transformLoad(responseData)
|
|
317
|
+
data.value = savedData as T
|
|
318
|
+
originalData.value = deepClone(savedData)
|
|
319
|
+
takeSnapshot()
|
|
320
|
+
|
|
321
|
+
if (onSaveSuccess) {
|
|
322
|
+
await onSaveSuccess(responseData, andClose)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (andClose) {
|
|
326
|
+
// Navigate to list route (or previous page)
|
|
327
|
+
const listRoute = findListRoute()
|
|
328
|
+
router.push(listRoute as { name: string })
|
|
329
|
+
} else if (!isEdit.value) {
|
|
330
|
+
// "Create" without close: navigate to edit route for the created entity
|
|
331
|
+
const createdId = (responseData as Record<string, unknown>)?.[manager.idField]
|
|
332
|
+
if (createdId) {
|
|
333
|
+
const currentRouteName = (route.name as string) || ''
|
|
334
|
+
const editRouteName = currentRouteName.replace(/(-create|-new)$/, '-edit')
|
|
335
|
+
router.push({
|
|
336
|
+
name: editRouteName,
|
|
337
|
+
params: { ...route.params, [manager.idField]: String(createdId) },
|
|
338
|
+
} as { name: string; params: Record<string, string> })
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return responseData
|
|
343
|
+
} catch (error) {
|
|
344
|
+
const axiosError = error as AxiosError
|
|
345
|
+
toast.add({
|
|
346
|
+
severity: 'error',
|
|
347
|
+
summary: 'Error',
|
|
348
|
+
detail: axiosError.response?.data?.detail || `Failed to save ${entityName}`,
|
|
349
|
+
life: 5000,
|
|
350
|
+
})
|
|
351
|
+
throw error
|
|
352
|
+
} finally {
|
|
353
|
+
saving.value = false
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============ DELETE ============
|
|
358
|
+
|
|
359
|
+
async function remove(): Promise<void> {
|
|
360
|
+
if (!isEdit.value) return
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
await manager.delete(entityId.value!)
|
|
364
|
+
toast.add({
|
|
365
|
+
severity: 'success',
|
|
366
|
+
summary: 'Deleted',
|
|
367
|
+
detail: `${entityName} deleted successfully`,
|
|
368
|
+
life: 3000,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
if (onDeleteSuccess) {
|
|
372
|
+
await onDeleteSuccess()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
router.push({ name: routePrefix })
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const axiosError = error as AxiosError
|
|
378
|
+
toast.add({
|
|
379
|
+
severity: 'error',
|
|
380
|
+
summary: 'Error',
|
|
381
|
+
detail: axiosError.response?.data?.detail || `Failed to delete ${entityName}`,
|
|
382
|
+
life: 5000,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function confirmDelete(): void {
|
|
388
|
+
const label = manager.getEntityLabel(data.value) || entityId.value
|
|
389
|
+
confirm.require({
|
|
390
|
+
message: `Delete ${entityName} "${label}"?`,
|
|
391
|
+
header: 'Confirm Delete',
|
|
392
|
+
icon: 'pi pi-exclamation-triangle',
|
|
393
|
+
acceptClass: 'p-button-danger',
|
|
394
|
+
accept: remove,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============ RESET ============
|
|
399
|
+
|
|
400
|
+
function reset(): void {
|
|
401
|
+
if (originalData.value) {
|
|
402
|
+
data.value = deepClone(originalData.value) as T
|
|
403
|
+
} else {
|
|
404
|
+
data.value = deepClone(initialData) as T
|
|
405
|
+
}
|
|
406
|
+
takeSnapshot()
|
|
407
|
+
// Clear validation state on reset
|
|
408
|
+
errors.value = {}
|
|
409
|
+
submitted.value = false
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============ NAVIGATION ============
|
|
413
|
+
|
|
414
|
+
function findListRoute(): { name: string; params?: Record<string, unknown> } {
|
|
415
|
+
// If has parent config, find sibling list route
|
|
416
|
+
if (parentConfig.value) {
|
|
417
|
+
const { entity: parentEntityName, param } = parentConfig.value
|
|
418
|
+
const siblings = getSiblingRoutes(parentEntityName, param)
|
|
419
|
+
|
|
420
|
+
const currentRouteName = (route.name as string) || ''
|
|
421
|
+
const baseRouteName = currentRouteName.replace(/-(create|edit|new)$/, '')
|
|
422
|
+
|
|
423
|
+
const listRoutes = siblings.filter((r) => {
|
|
424
|
+
const name = (r.name as string) || ''
|
|
425
|
+
const path = (r.path as string) || ''
|
|
426
|
+
return !name.match(/-(create|edit|new)$/) && !path.match(/\/(create|edit|new)$/)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
let listRoute = listRoutes.find((r) => r.name === baseRouteName)
|
|
430
|
+
|
|
431
|
+
if (!listRoute && routePrefix) {
|
|
432
|
+
listRoute = listRoutes.find((r) => (r.name as string)?.includes(routePrefix))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!listRoute && listRoutes.length > 0) {
|
|
436
|
+
listRoute = listRoutes[0]
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (listRoute?.name) {
|
|
440
|
+
const params = { ...route.params } as Record<string, unknown>
|
|
441
|
+
delete params[manager.idField]
|
|
442
|
+
return { name: listRoute.name as string, params }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { name: routePrefix! }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function cancel(): void {
|
|
450
|
+
router.push(findListRoute() as { name: string })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function goToList(): void {
|
|
454
|
+
router.push(findListRoute() as { name: string })
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============ FIELDS & GROUPS (via useFieldManager) ============
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Form-specific field resolver
|
|
461
|
+
*/
|
|
462
|
+
function resolveFieldConfig(
|
|
463
|
+
name: string,
|
|
464
|
+
fieldConfig: Partial<FieldDefinition>
|
|
465
|
+
): ResolvedFieldConfig {
|
|
466
|
+
const {
|
|
467
|
+
type = 'text',
|
|
468
|
+
label,
|
|
469
|
+
required = false,
|
|
470
|
+
default: defaultValue,
|
|
471
|
+
options,
|
|
472
|
+
optionLabel = 'label',
|
|
473
|
+
optionValue = 'value',
|
|
474
|
+
placeholder,
|
|
475
|
+
disabled = false,
|
|
476
|
+
readonly = false,
|
|
477
|
+
...rest
|
|
478
|
+
} = fieldConfig
|
|
479
|
+
|
|
480
|
+
const inputType = TYPE_MAPPINGS[type] || 'text'
|
|
481
|
+
const resolvedLabel = label || snakeCaseToTitle(name)
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
name,
|
|
485
|
+
type: inputType,
|
|
486
|
+
schemaType: type,
|
|
487
|
+
label: resolvedLabel,
|
|
488
|
+
required,
|
|
489
|
+
default: defaultValue,
|
|
490
|
+
options: options as unknown[] | undefined,
|
|
491
|
+
optionLabel,
|
|
492
|
+
optionValue,
|
|
493
|
+
placeholder,
|
|
494
|
+
disabled,
|
|
495
|
+
readonly,
|
|
496
|
+
...rest,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Use shared field manager
|
|
501
|
+
const fieldManager = useFieldManager<ResolvedFieldConfig>({
|
|
502
|
+
resolveFieldConfig,
|
|
503
|
+
getSchemaFieldConfig: (name) => manager.getFieldConfig(name) || null,
|
|
504
|
+
entity,
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// Expose refs for validation (which needs direct access)
|
|
508
|
+
const { fieldsMap, fieldOrder, excludedFields, fields, groups } = fieldManager
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Generate fields from manager schema
|
|
512
|
+
*/
|
|
513
|
+
function generateFields(options: GenerateFieldsOptions = {}): UseEntityItemFormPageReturn<T> {
|
|
514
|
+
const formFields = manager.getFormFields()
|
|
515
|
+
fieldManager.generateFields(formFields, options)
|
|
516
|
+
// Auto-resolve reference options (async, non-blocking)
|
|
517
|
+
resolveReferences()
|
|
518
|
+
return builderApi
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Resolve reference field options from related entities
|
|
523
|
+
*/
|
|
524
|
+
async function resolveReferences(): Promise<void> {
|
|
525
|
+
for (const [name, config] of fieldsMap.value.entries()) {
|
|
526
|
+
if (config.options || !config.reference) continue
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const options = await manager.resolveReferenceOptions(name)
|
|
530
|
+
fieldManager.updateField(name, { options })
|
|
531
|
+
} catch (error) {
|
|
532
|
+
console.warn(
|
|
533
|
+
`[useEntityItemFormPage] Failed to resolve options for field '${name}':`,
|
|
534
|
+
error
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Chainable method wrapper (returns builderApi for fluent API)
|
|
541
|
+
const chain = <A extends unknown[], R>(fn: (...args: A) => R) =>
|
|
542
|
+
(...args: A): UseEntityItemFormPageReturn<T> => { fn(...args); return builderApi }
|
|
543
|
+
|
|
544
|
+
// Field management (chainable)
|
|
545
|
+
const addField = chain(fieldManager.addField)
|
|
546
|
+
const removeField = chain(fieldManager.removeField)
|
|
547
|
+
const excludeField = chain(fieldManager.excludeField)
|
|
548
|
+
const setFieldOrder = chain(fieldManager.setFieldOrder)
|
|
549
|
+
const moveField = chain(fieldManager.moveField)
|
|
550
|
+
|
|
551
|
+
// updateField needs custom validation logic
|
|
552
|
+
function updateField(name: string, fieldConfig: Partial<FieldDefinition>): UseEntityItemFormPageReturn<T> {
|
|
553
|
+
if (!fieldsMap.value.has(name)) {
|
|
554
|
+
throw new Error(`Field '${name}' does not exist. Use addField() to create new fields.`)
|
|
555
|
+
}
|
|
556
|
+
fieldManager.updateField(name, fieldConfig)
|
|
557
|
+
return builderApi
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Direct accessors
|
|
561
|
+
const getFieldConfig = fieldManager.getField
|
|
562
|
+
const getFields = (): ResolvedFieldConfig[] => fields.value
|
|
563
|
+
|
|
564
|
+
// Group management (chainable)
|
|
565
|
+
const group = chain(fieldManager.group)
|
|
566
|
+
const defineGroups = chain(fieldManager.defineGroups)
|
|
567
|
+
|
|
568
|
+
// Group accessors
|
|
569
|
+
const getGroup = fieldManager.getGroup
|
|
570
|
+
const getFieldsByGroup = fieldManager.getFieldsByGroup
|
|
571
|
+
const getUngroupedFields = fieldManager.getUngroupedFields
|
|
572
|
+
|
|
573
|
+
// ============ VALIDATION ============
|
|
574
|
+
|
|
575
|
+
function validateField(name: string): string | null {
|
|
576
|
+
const fieldConfig = fieldsMap.value.get(name)
|
|
577
|
+
if (!fieldConfig) return null
|
|
578
|
+
|
|
579
|
+
const value = data.value[name]
|
|
580
|
+
|
|
581
|
+
if (fieldConfig.required && isEmpty(value)) {
|
|
582
|
+
const error = `${fieldConfig.label} is required`
|
|
583
|
+
errors.value = { ...errors.value, [name]: error }
|
|
584
|
+
return error
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
|
|
588
|
+
if (typeValidator) {
|
|
589
|
+
const result = typeValidator(value)
|
|
590
|
+
if (result !== true) {
|
|
591
|
+
errors.value = { ...errors.value, [name]: result as string }
|
|
592
|
+
return result as string
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
|
|
597
|
+
const result = fieldConfig.validate(value, data.value)
|
|
598
|
+
if (result !== true && result !== undefined && result !== null) {
|
|
599
|
+
const error = typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
|
|
600
|
+
errors.value = { ...errors.value, [name]: error }
|
|
601
|
+
return error
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (errors.value[name]) {
|
|
606
|
+
const { [name]: _removed, ...rest } = errors.value
|
|
607
|
+
void _removed // Intentionally unused - destructuring to omit
|
|
608
|
+
errors.value = rest
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return null
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function validate(): boolean {
|
|
615
|
+
const newErrors: Record<string, string> = {}
|
|
616
|
+
|
|
617
|
+
for (const fieldName of fieldOrder.value) {
|
|
618
|
+
const fieldConfig = fieldsMap.value.get(fieldName)
|
|
619
|
+
if (!fieldConfig) continue
|
|
620
|
+
|
|
621
|
+
const value = data.value[fieldName]
|
|
622
|
+
|
|
623
|
+
if (fieldConfig.required && isEmpty(value)) {
|
|
624
|
+
newErrors[fieldName] = `${fieldConfig.label} is required`
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
|
|
629
|
+
if (typeValidator) {
|
|
630
|
+
const result = typeValidator(value)
|
|
631
|
+
if (result !== true) {
|
|
632
|
+
newErrors[fieldName] = result as string
|
|
633
|
+
continue
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
|
|
638
|
+
const result = fieldConfig.validate(value, data.value)
|
|
639
|
+
if (result !== true && result !== undefined && result !== null) {
|
|
640
|
+
newErrors[fieldName] =
|
|
641
|
+
typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
errors.value = newErrors
|
|
647
|
+
return Object.keys(newErrors).length === 0
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function clearErrors(): void {
|
|
651
|
+
errors.value = {}
|
|
652
|
+
submitted.value = false
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function clearFieldError(name: string): void {
|
|
656
|
+
if (errors.value[name]) {
|
|
657
|
+
const { [name]: _removed, ...rest } = errors.value
|
|
658
|
+
void _removed // Intentionally unused - destructuring to omit
|
|
659
|
+
errors.value = rest
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function getFieldError(name: string): string | null {
|
|
664
|
+
return errors.value[name] || null
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function handleFieldBlur(name: string): void {
|
|
668
|
+
if (validateOnBlur) {
|
|
669
|
+
validateField(name)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
provide('handleFieldBlur', handleFieldBlur)
|
|
674
|
+
|
|
675
|
+
// ============ PERMISSION STATE ============
|
|
676
|
+
|
|
677
|
+
const canSave = computed(() => {
|
|
678
|
+
return isEdit.value ? manager.canUpdate(data.value) : manager.canCreate()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
const canDeleteRecord = computed(() => {
|
|
682
|
+
return isEdit.value && manager.canDelete(data.value)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
// ============ ACTIONS ============
|
|
686
|
+
|
|
687
|
+
const actionsMap = ref<Map<string, ActionConfig>>(new Map())
|
|
688
|
+
|
|
689
|
+
function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
|
|
690
|
+
actionsMap.value.set(name, { name, ...actionConfig })
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function removeAction(name: string): void {
|
|
694
|
+
actionsMap.value.delete(name)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function getActions(): ResolvedAction[] {
|
|
698
|
+
const actions: ResolvedAction[] = []
|
|
699
|
+
for (const [, action] of actionsMap.value) {
|
|
700
|
+
if (action.visible && !action.visible({ isEdit: isEdit.value, dirty: dirty.value })) continue
|
|
701
|
+
actions.push({
|
|
702
|
+
...action,
|
|
703
|
+
isLoading: action.loading ? action.loading() : false,
|
|
704
|
+
isDisabled: action.disabled
|
|
705
|
+
? action.disabled({ dirty: dirty.value, saving: saving.value })
|
|
706
|
+
: false,
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
return actions
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const actions = computed(() => getActions())
|
|
713
|
+
|
|
714
|
+
function addSaveAction(options: AddSaveActionOptions = {}): void {
|
|
715
|
+
const { label, andClose = true } = options
|
|
716
|
+
const actionLabel = label || (andClose ? 'Save & Close' : 'Save')
|
|
717
|
+
|
|
718
|
+
addAction('save', {
|
|
719
|
+
label: actionLabel,
|
|
720
|
+
icon: andClose ? 'pi pi-check-circle' : 'pi pi-check',
|
|
721
|
+
severity: andClose ? 'success' : 'primary',
|
|
722
|
+
onClick: async () => { await submit(andClose) },
|
|
723
|
+
visible: () => (isEdit.value ? manager.canUpdate() : manager.canCreate()),
|
|
724
|
+
disabled: ({ dirty, saving }) => !dirty || saving,
|
|
725
|
+
loading: () => saving.value,
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function addDeleteAction(options: AddDeleteActionOptions = {}): void {
|
|
730
|
+
const { label = 'Delete' } = options
|
|
731
|
+
|
|
732
|
+
addAction('delete', {
|
|
733
|
+
label,
|
|
734
|
+
icon: 'pi pi-trash',
|
|
735
|
+
severity: 'danger',
|
|
736
|
+
onClick: confirmDelete,
|
|
737
|
+
visible: () => isEdit.value && manager.canDelete(data.value),
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function addCancelAction(options: AddCancelActionOptions = {}): void {
|
|
742
|
+
const { label = 'Cancel' } = options
|
|
743
|
+
|
|
744
|
+
addAction('cancel', {
|
|
745
|
+
label,
|
|
746
|
+
icon: 'pi pi-times',
|
|
747
|
+
severity: 'secondary',
|
|
748
|
+
onClick: cancel,
|
|
749
|
+
disabled: ({ saving }) => saving,
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ============ TITLE ============
|
|
754
|
+
|
|
755
|
+
const entityLabel = computed(() => {
|
|
756
|
+
return manager.getEntityLabel(data.value)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
const pageTitle = computed(() => {
|
|
760
|
+
if (isEdit.value) {
|
|
761
|
+
const label = entityLabel.value
|
|
762
|
+
return label ? `Edit ${entityName}: ${label}` : `Edit ${entityName}`
|
|
763
|
+
}
|
|
764
|
+
return `Create ${entityName}`
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
const pageTitleParts = computed<PageTitleParts>(() => ({
|
|
768
|
+
action: isEdit.value ? 'Edit' : 'Create',
|
|
769
|
+
entityName,
|
|
770
|
+
entityLabel: isEdit.value ? entityLabel.value : undefined,
|
|
771
|
+
}))
|
|
772
|
+
|
|
773
|
+
provide('qdadmPageTitleParts', pageTitleParts)
|
|
774
|
+
|
|
775
|
+
// ============ LIFECYCLE ============
|
|
776
|
+
|
|
777
|
+
onMounted(() => {
|
|
778
|
+
if (loadOnMount) {
|
|
779
|
+
load()
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
watch(entityId, (newId, oldId) => {
|
|
784
|
+
if (newId !== oldId && loadOnMount) {
|
|
785
|
+
load()
|
|
786
|
+
}
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
// ============ FORMPAGE PROPS/EVENTS ============
|
|
790
|
+
|
|
791
|
+
const formProps = computed<FormPageProps>(() => ({
|
|
792
|
+
isEdit: isEdit.value,
|
|
793
|
+
mode: mode.value,
|
|
794
|
+
loading: loading.value,
|
|
795
|
+
saving: saving.value,
|
|
796
|
+
dirty: dirty.value,
|
|
797
|
+
title: pageTitle.value,
|
|
798
|
+
titleParts: pageTitleParts.value,
|
|
799
|
+
fields: fields.value,
|
|
800
|
+
groups: groups.value,
|
|
801
|
+
actions: actions.value,
|
|
802
|
+
canSave: canSave.value,
|
|
803
|
+
canDelete: canDeleteRecord.value,
|
|
804
|
+
errors: errors.value,
|
|
805
|
+
hasErrors: hasErrors.value,
|
|
806
|
+
errorSummary: showErrorSummary ? errorSummary.value : null,
|
|
807
|
+
submitted: submitted.value,
|
|
808
|
+
guardDialog,
|
|
809
|
+
}))
|
|
810
|
+
|
|
811
|
+
const formEvents: FormPageEvents = {
|
|
812
|
+
save: () => submit(false),
|
|
813
|
+
saveAndClose: () => submit(true),
|
|
814
|
+
cancel,
|
|
815
|
+
delete: confirmDelete,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ============ BUILDER API ============
|
|
819
|
+
|
|
820
|
+
const builderApi: UseEntityItemFormPageReturn<T> = {
|
|
821
|
+
// Manager access
|
|
822
|
+
manager,
|
|
823
|
+
|
|
824
|
+
// Mode
|
|
825
|
+
mode,
|
|
826
|
+
isEdit,
|
|
827
|
+
isCreate,
|
|
828
|
+
entityId,
|
|
829
|
+
|
|
830
|
+
// Parent chain
|
|
831
|
+
parentConfig,
|
|
832
|
+
parentId,
|
|
833
|
+
parentData,
|
|
834
|
+
parentChain,
|
|
835
|
+
getChainDepth,
|
|
836
|
+
|
|
837
|
+
// State
|
|
838
|
+
data,
|
|
839
|
+
loading,
|
|
840
|
+
saving,
|
|
841
|
+
dirty,
|
|
842
|
+
dirtyFields,
|
|
843
|
+
originalData,
|
|
844
|
+
|
|
845
|
+
// Actions
|
|
846
|
+
load,
|
|
847
|
+
submit,
|
|
848
|
+
cancel,
|
|
849
|
+
remove,
|
|
850
|
+
confirmDelete,
|
|
851
|
+
reset,
|
|
852
|
+
goToList,
|
|
853
|
+
findListRoute,
|
|
854
|
+
|
|
855
|
+
// Dirty tracking
|
|
856
|
+
takeSnapshot,
|
|
857
|
+
checkDirty,
|
|
858
|
+
isFieldDirty,
|
|
859
|
+
|
|
860
|
+
// Field management
|
|
861
|
+
fields,
|
|
862
|
+
generateFields,
|
|
863
|
+
resolveReferences,
|
|
864
|
+
addField,
|
|
865
|
+
updateField,
|
|
866
|
+
removeField,
|
|
867
|
+
excludeField,
|
|
868
|
+
getFieldConfig,
|
|
869
|
+
getFields,
|
|
870
|
+
setFieldOrder,
|
|
871
|
+
moveField,
|
|
872
|
+
|
|
873
|
+
// Group management
|
|
874
|
+
groups,
|
|
875
|
+
group,
|
|
876
|
+
defineGroups,
|
|
877
|
+
getGroup,
|
|
878
|
+
getFieldsByGroup,
|
|
879
|
+
getUngroupedFields,
|
|
880
|
+
|
|
881
|
+
// Validation
|
|
882
|
+
errors,
|
|
883
|
+
hasErrors,
|
|
884
|
+
errorSummary,
|
|
885
|
+
submitted,
|
|
886
|
+
validate,
|
|
887
|
+
validateField,
|
|
888
|
+
clearErrors,
|
|
889
|
+
clearFieldError,
|
|
890
|
+
getFieldError,
|
|
891
|
+
handleFieldBlur,
|
|
892
|
+
|
|
893
|
+
// Action management
|
|
894
|
+
actions,
|
|
895
|
+
addAction,
|
|
896
|
+
removeAction,
|
|
897
|
+
getActions,
|
|
898
|
+
addSaveAction,
|
|
899
|
+
addDeleteAction,
|
|
900
|
+
addCancelAction,
|
|
901
|
+
|
|
902
|
+
// Permissions
|
|
903
|
+
canSave,
|
|
904
|
+
canDeleteRecord,
|
|
905
|
+
|
|
906
|
+
// Breadcrumb
|
|
907
|
+
breadcrumb: breadcrumbItems,
|
|
908
|
+
|
|
909
|
+
// Guard dialog
|
|
910
|
+
guardDialog,
|
|
911
|
+
|
|
912
|
+
// Title helpers
|
|
913
|
+
entityLabel,
|
|
914
|
+
pageTitle,
|
|
915
|
+
pageTitleParts,
|
|
916
|
+
|
|
917
|
+
// Utilities
|
|
918
|
+
toast,
|
|
919
|
+
confirm,
|
|
920
|
+
router,
|
|
921
|
+
route,
|
|
922
|
+
|
|
923
|
+
// FormPage integration
|
|
924
|
+
props: formProps,
|
|
925
|
+
events: formEvents,
|
|
926
|
+
|
|
927
|
+
// Stack hydrator
|
|
928
|
+
hydrator,
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Auto-generate fields from manager schema if enabled
|
|
932
|
+
if (generateFormFields) {
|
|
933
|
+
generateFields()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Auto-disable parent foreignKey field (it's auto-filled from route)
|
|
937
|
+
if (parentConfig.value?.foreignKey && fieldsMap.value.has(parentConfig.value.foreignKey)) {
|
|
938
|
+
updateField(parentConfig.value.foreignKey, { disabled: true })
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return builderApi
|
|
942
|
+
}
|