@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,1535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListPage - Unified procedural builder for CRUD list pages
|
|
3
|
+
*
|
|
4
|
+
* Provides a declarative/procedural API to build list pages with:
|
|
5
|
+
* - Cards zone (stats, custom content)
|
|
6
|
+
* - Filter bar with search and custom filters
|
|
7
|
+
* - DataTable with columns and actions
|
|
8
|
+
* - Bulk selection and operations
|
|
9
|
+
*
|
|
10
|
+
* ## Filter Modes
|
|
11
|
+
*
|
|
12
|
+
* The threshold decides everything:
|
|
13
|
+
* - `items >= threshold` → **manager mode**: delegate ALL filtering to EntityManager
|
|
14
|
+
* - `items < threshold` → **local mode**: filter client-side
|
|
15
|
+
*
|
|
16
|
+
* Modes:
|
|
17
|
+
* - `filterMode: 'manager'` - Always delegate to EntityManager
|
|
18
|
+
* - `filterMode: 'local'` - Always filter client-side
|
|
19
|
+
* - `filterMode: 'auto'` (default) - Switch to local if items < threshold
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
ref,
|
|
23
|
+
computed,
|
|
24
|
+
watch,
|
|
25
|
+
onMounted,
|
|
26
|
+
onUnmounted,
|
|
27
|
+
inject,
|
|
28
|
+
provide,
|
|
29
|
+
type Ref,
|
|
30
|
+
} from 'vue'
|
|
31
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
32
|
+
import { useConfirm } from 'primevue/useconfirm'
|
|
33
|
+
import { useHooks } from './useHooks.js'
|
|
34
|
+
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
35
|
+
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
|
+
import { FilterQuery } from '../query/FilterQuery'
|
|
37
|
+
import { useI18n } from '../i18n/useI18n'
|
|
38
|
+
|
|
39
|
+
// Import types from dedicated types file
|
|
40
|
+
import type {
|
|
41
|
+
Orchestrator,
|
|
42
|
+
ToastHelper,
|
|
43
|
+
ConfirmService,
|
|
44
|
+
ColumnConfig,
|
|
45
|
+
FilterConfig,
|
|
46
|
+
ActionConfig,
|
|
47
|
+
ResolvedAction,
|
|
48
|
+
HeaderActionState,
|
|
49
|
+
HeaderActionConfig,
|
|
50
|
+
ResolvedHeaderAction,
|
|
51
|
+
CardConfig,
|
|
52
|
+
SearchConfig,
|
|
53
|
+
EntityContext,
|
|
54
|
+
BulkStatusActionReturn,
|
|
55
|
+
ListResponse,
|
|
56
|
+
AxiosError,
|
|
57
|
+
UseListPageOptions,
|
|
58
|
+
ListPageProps,
|
|
59
|
+
ListPageEvents,
|
|
60
|
+
CreateActionOptions,
|
|
61
|
+
BulkStatusActionOptions,
|
|
62
|
+
UseListPageReturn,
|
|
63
|
+
} from './useListPage.types'
|
|
64
|
+
|
|
65
|
+
// Re-export public types
|
|
66
|
+
export type {
|
|
67
|
+
ColumnConfig,
|
|
68
|
+
FilterConfig,
|
|
69
|
+
ActionConfig,
|
|
70
|
+
ResolvedAction,
|
|
71
|
+
HeaderActionConfig,
|
|
72
|
+
ResolvedHeaderAction,
|
|
73
|
+
CardConfig,
|
|
74
|
+
SearchConfig,
|
|
75
|
+
BulkStatusActionReturn,
|
|
76
|
+
UseListPageOptions,
|
|
77
|
+
ListPageProps,
|
|
78
|
+
ListPageEvents,
|
|
79
|
+
CreateActionOptions,
|
|
80
|
+
BulkStatusActionOptions,
|
|
81
|
+
UseListPageReturn,
|
|
82
|
+
} from './useListPage.types'
|
|
83
|
+
|
|
84
|
+
// Stateless utilities (cookies, session storage, formatters, constants).
|
|
85
|
+
import {
|
|
86
|
+
PAGE_SIZE_OPTIONS,
|
|
87
|
+
SMART_FILTER_THRESHOLD,
|
|
88
|
+
clearSessionFilters,
|
|
89
|
+
getSavedPageSize,
|
|
90
|
+
getSessionFilters,
|
|
91
|
+
persistPageSize,
|
|
92
|
+
setSessionFilters,
|
|
93
|
+
snakeToTitle,
|
|
94
|
+
} from './useListPage.utils'
|
|
95
|
+
|
|
96
|
+
// Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
|
|
97
|
+
// composables/index.ts) keep their import path.
|
|
98
|
+
export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Unified procedural builder for CRUD list pages
|
|
103
|
+
*
|
|
104
|
+
* @param config - Configuration options
|
|
105
|
+
* @returns List page API
|
|
106
|
+
*/
|
|
107
|
+
export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseListPageReturn<T> {
|
|
108
|
+
const {
|
|
109
|
+
entity,
|
|
110
|
+
dataKey,
|
|
111
|
+
defaultSort = null,
|
|
112
|
+
defaultSortOrder = -1,
|
|
113
|
+
pageSize: defaultPageSize = 10,
|
|
114
|
+
loadOnMount = true,
|
|
115
|
+
persistFilters = true,
|
|
116
|
+
syncUrlParams = true,
|
|
117
|
+
autoLoadFilters = true,
|
|
118
|
+
onBeforeLoad = null,
|
|
119
|
+
onAfterLoad = null,
|
|
120
|
+
transformResponse = null,
|
|
121
|
+
} = config
|
|
122
|
+
|
|
123
|
+
const router = useRouter()
|
|
124
|
+
const route = useRoute()
|
|
125
|
+
const confirm = useConfirm() as ConfirmService
|
|
126
|
+
|
|
127
|
+
// Mobile detection (viewport width < 768px)
|
|
128
|
+
const isMobile = ref(window.innerWidth < 768)
|
|
129
|
+
|
|
130
|
+
// i18n integration — column headers resolve via entities.{entity}.fields.{field}
|
|
131
|
+
const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
|
|
132
|
+
|
|
133
|
+
// Get EntityManager via orchestrator
|
|
134
|
+
const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
|
|
135
|
+
|
|
136
|
+
// Toast helper - wraps orchestrator.toast for legacy compatibility
|
|
137
|
+
const toast: ToastHelper = {
|
|
138
|
+
add({ severity, summary, detail, emitter }) {
|
|
139
|
+
orchestrator?.toast[severity]?.(summary, detail, emitter)
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!orchestrator) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'[qdadm] Orchestrator not provided.\n' +
|
|
146
|
+
'Possible causes:\n' +
|
|
147
|
+
'1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
|
|
148
|
+
'2. Component used outside of qdadm app context\n' +
|
|
149
|
+
'3. Missing entityFactory in Kernel options'
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
const manager = orchestrator.get(entity)
|
|
153
|
+
|
|
154
|
+
// Provide entity context for child components (e.g., SeverityTag auto-discovery)
|
|
155
|
+
provide('mainEntity', entity)
|
|
156
|
+
|
|
157
|
+
// ============ PARENT CHAIN SUPPORT ============
|
|
158
|
+
|
|
159
|
+
const parentConfigComputed = computed<ParentConfig | null>(
|
|
160
|
+
() => (route.meta?.parent as ParentConfig) || null
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// Use ActiveStack for synchronous route-based context
|
|
164
|
+
useActiveStack()
|
|
165
|
+
|
|
166
|
+
// Create useEntityItemPage for PARENT entity (not for list's own entity)
|
|
167
|
+
let parentPage: UseEntityItemPageReturn | null = null
|
|
168
|
+
if (route.meta?.parent) {
|
|
169
|
+
const parentMeta = route.meta.parent as ParentConfig
|
|
170
|
+
parentPage = useEntityItemPage({
|
|
171
|
+
entity: parentMeta.entity,
|
|
172
|
+
loadOnMount: true,
|
|
173
|
+
autoLoadParent: true,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Parent data and loading state (null if no parent)
|
|
178
|
+
const parentData = computed(() => parentPage?.data.value || null)
|
|
179
|
+
const parentId = computed(() => parentPage?.entityId.value || null)
|
|
180
|
+
const parentLoading = computed(() => parentPage?.loading.value || false)
|
|
181
|
+
const parentChain = computed(() => parentPage?.parentChain.value || new Map())
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build entity context with parentChain array for multi-storage routing
|
|
185
|
+
*/
|
|
186
|
+
const entityContext = computed<EntityContext | null>(() => {
|
|
187
|
+
if (!parentConfigComputed.value) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const chain: Array<{ entity: string; id: string | number }> = []
|
|
192
|
+
let currentConfig: ParentConfig | undefined = parentConfigComputed.value
|
|
193
|
+
|
|
194
|
+
while (currentConfig) {
|
|
195
|
+
const entityId = route.params[currentConfig.param] as string
|
|
196
|
+
if (!entityId) break
|
|
197
|
+
|
|
198
|
+
chain.unshift({
|
|
199
|
+
entity: currentConfig.entity,
|
|
200
|
+
id: entityId,
|
|
201
|
+
})
|
|
202
|
+
currentConfig = currentConfig.parent
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return chain.length > 0 ? { parentChain: chain } : null
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Read config from manager with option overrides
|
|
209
|
+
const entityName = config.entityName ?? manager.label ?? entity
|
|
210
|
+
const entityNamePlural = config.entityNamePlural ?? manager.labelPlural ?? `${entityName}s`
|
|
211
|
+
const routePrefix = config.routePrefix ?? manager.routePrefix ?? entity
|
|
212
|
+
const resolvedDataKey = dataKey ?? manager.idField ?? 'id'
|
|
213
|
+
|
|
214
|
+
// Session key for filter persistence (based on entity name)
|
|
215
|
+
const filterSessionKey = entity || entityName
|
|
216
|
+
|
|
217
|
+
// Entity filters registry (optional, provided by consuming app)
|
|
218
|
+
const entityFilters = inject<Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>>(
|
|
219
|
+
'qdadmEntityFilters',
|
|
220
|
+
{}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// Get HookRegistry for list:alter hook (optional, may not exist in tests)
|
|
224
|
+
const hooks = useHooks()
|
|
225
|
+
|
|
226
|
+
// ============ SESSION RESTORE ============
|
|
227
|
+
const savedSession = persistFilters ? getSessionFilters(filterSessionKey) : null
|
|
228
|
+
const savedSearch = (savedSession?._search as string) || ''
|
|
229
|
+
const savedFilters: Record<string, unknown> | null = savedSession ? { ...savedSession } : null
|
|
230
|
+
if (savedFilters) delete savedFilters._search
|
|
231
|
+
|
|
232
|
+
// ============ STATE ============
|
|
233
|
+
const items = ref<T[]>([]) as Ref<T[]>
|
|
234
|
+
const loading = ref(false)
|
|
235
|
+
const selected = ref<T[]>([]) as Ref<T[]>
|
|
236
|
+
const deleting = ref(false)
|
|
237
|
+
|
|
238
|
+
// Pagination
|
|
239
|
+
const page = ref(1)
|
|
240
|
+
const pageSize = ref(getSavedPageSize(defaultPageSize))
|
|
241
|
+
const totalRecords = ref(0)
|
|
242
|
+
const rowsPerPageOptions = PAGE_SIZE_OPTIONS
|
|
243
|
+
|
|
244
|
+
// Sorting
|
|
245
|
+
const sortField = ref<string | null>(defaultSort)
|
|
246
|
+
const sortOrder = ref(defaultSortOrder)
|
|
247
|
+
|
|
248
|
+
// Search
|
|
249
|
+
const searchQuery = ref(savedSearch)
|
|
250
|
+
const searchConfig = ref<SearchConfig>({
|
|
251
|
+
placeholder: 'Search...',
|
|
252
|
+
fields: [],
|
|
253
|
+
debounce: 300,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// ============ COLUMNS ============
|
|
257
|
+
const columnsMap = ref<Map<string, ColumnConfig>>(new Map())
|
|
258
|
+
// Track inline-supplied column headers so relabel() can fall back to them
|
|
259
|
+
// when the i18n bundle has no translation for the field.
|
|
260
|
+
const columnInlineHeaders = new Map<string, string | undefined>()
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
|
|
264
|
+
* otherwise the inline `header` option, otherwise a capitalized field name.
|
|
265
|
+
*/
|
|
266
|
+
function resolveColumnHeader(field: string, inline?: string): string {
|
|
267
|
+
if (entity && kernelI18n) {
|
|
268
|
+
const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
|
|
269
|
+
if (trace.hit) return trace.result
|
|
270
|
+
}
|
|
271
|
+
return inline || field.charAt(0).toUpperCase() + field.slice(1)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
|
|
275
|
+
columnInlineHeaders.set(field, columnConfig.header)
|
|
276
|
+
columnsMap.value.set(field, {
|
|
277
|
+
field,
|
|
278
|
+
header: resolveColumnHeader(field, columnConfig.header),
|
|
279
|
+
...columnConfig,
|
|
280
|
+
// Re-apply resolved header after spread so it wins over a stale inline.
|
|
281
|
+
...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Re-resolve column headers when locale changes.
|
|
286
|
+
watch(i18nLocale, () => {
|
|
287
|
+
if (!entity || !kernelI18n) return
|
|
288
|
+
for (const [field, current] of columnsMap.value.entries()) {
|
|
289
|
+
const newHeader = resolveColumnHeader(field, columnInlineHeaders.get(field))
|
|
290
|
+
if (current.header !== newHeader) {
|
|
291
|
+
columnsMap.value.set(field, { ...current, header: newHeader })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
function removeColumn(field: string): void {
|
|
297
|
+
columnsMap.value.delete(field)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function updateColumn(field: string, updates: Partial<ColumnConfig>): void {
|
|
301
|
+
const existing = columnsMap.value.get(field)
|
|
302
|
+
if (existing) {
|
|
303
|
+
columnsMap.value.set(field, { ...existing, ...updates })
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const columns = computed(() => Array.from(columnsMap.value.values()))
|
|
308
|
+
|
|
309
|
+
// ============ HEADER ACTIONS ============
|
|
310
|
+
const headerActionsMap = ref<Map<string, HeaderActionConfig>>(new Map())
|
|
311
|
+
|
|
312
|
+
function addHeaderAction(name: string, config: Omit<HeaderActionConfig, 'name'>): void {
|
|
313
|
+
headerActionsMap.value.set(name, { name, ...config })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function removeHeaderAction(name: string): void {
|
|
317
|
+
headerActionsMap.value.delete(name)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getHeaderActions(): ResolvedHeaderAction[] {
|
|
321
|
+
const state: HeaderActionState = {
|
|
322
|
+
hasSelection: hasSelection.value,
|
|
323
|
+
selectionCount: selectionCount.value,
|
|
324
|
+
deleting: deleting.value,
|
|
325
|
+
}
|
|
326
|
+
const actions: ResolvedHeaderAction[] = []
|
|
327
|
+
for (const [, action] of headerActionsMap.value) {
|
|
328
|
+
if (action.visible && !action.visible(state)) continue
|
|
329
|
+
actions.push({
|
|
330
|
+
...action,
|
|
331
|
+
isLoading: action.loading ? action.loading(state) : false,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
return actions
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const headerActions = computed(() => getHeaderActions())
|
|
338
|
+
|
|
339
|
+
// ============ PERMISSION STATE ============
|
|
340
|
+
|
|
341
|
+
const canCreate = computed(() => manager.canCreate())
|
|
342
|
+
const canDelete = computed(() => manager.canDelete())
|
|
343
|
+
|
|
344
|
+
function canEditRow(row: unknown): boolean {
|
|
345
|
+
return manager.canUpdate(row)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function canDeleteRow(row: unknown): boolean {
|
|
349
|
+
return manager.canDelete(row)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function getRowActions(row: unknown): ResolvedAction[] {
|
|
353
|
+
return getActions(row)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function addCreateAction(labelOrOptions: string | CreateActionOptions | null = null): void {
|
|
357
|
+
const options: CreateActionOptions =
|
|
358
|
+
typeof labelOrOptions === 'string' ? { label: labelOrOptions } : labelOrOptions || {}
|
|
359
|
+
|
|
360
|
+
const createLabel =
|
|
361
|
+
options.label || `Create ${entityName.charAt(0).toUpperCase() + entityName.slice(1)}`
|
|
362
|
+
|
|
363
|
+
const onClick = options.routeName
|
|
364
|
+
? () =>
|
|
365
|
+
router.push({
|
|
366
|
+
name: options.routeName!,
|
|
367
|
+
params: { ...route.params, ...options.params },
|
|
368
|
+
} as { name: string; params: Record<string, string> })
|
|
369
|
+
: goToCreate
|
|
370
|
+
|
|
371
|
+
addHeaderAction('create', {
|
|
372
|
+
label: createLabel,
|
|
373
|
+
icon: 'pi pi-plus',
|
|
374
|
+
onClick,
|
|
375
|
+
visible: () => manager.canCreate(),
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function addBulkDeleteAction(): void {
|
|
380
|
+
addHeaderAction('bulk-delete', {
|
|
381
|
+
label: (state) => `Delete (${state.selectionCount})`,
|
|
382
|
+
icon: 'pi pi-trash',
|
|
383
|
+
severity: 'danger',
|
|
384
|
+
onClick: confirmBulkDelete,
|
|
385
|
+
visible: (state) => state.hasSelection && manager.canDelete(),
|
|
386
|
+
loading: (state) => state.deleting,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============ BULK STATUS ACTION ============
|
|
391
|
+
|
|
392
|
+
function addBulkStatusAction(bulkConfig: BulkStatusActionOptions = {}): BulkStatusActionReturn {
|
|
393
|
+
const {
|
|
394
|
+
statusField = 'status',
|
|
395
|
+
idsField = 'ids',
|
|
396
|
+
bulkEndpoint = null,
|
|
397
|
+
options = [],
|
|
398
|
+
label = 'Change Status',
|
|
399
|
+
icon = 'pi pi-sync',
|
|
400
|
+
dialogTitle = 'Change Status',
|
|
401
|
+
} = bulkConfig
|
|
402
|
+
|
|
403
|
+
const showDialog = ref(false)
|
|
404
|
+
const selectedStatus = ref<unknown>(null)
|
|
405
|
+
const updating = ref(false)
|
|
406
|
+
|
|
407
|
+
addHeaderAction('bulk-status', {
|
|
408
|
+
label: (state) => `${label} (${state.selectionCount})`,
|
|
409
|
+
icon,
|
|
410
|
+
severity: 'info',
|
|
411
|
+
onClick: () => {
|
|
412
|
+
showDialog.value = true
|
|
413
|
+
},
|
|
414
|
+
visible: (state) => state.hasSelection,
|
|
415
|
+
loading: () => updating.value,
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
async function execute(): Promise<void> {
|
|
419
|
+
if (!selectedStatus.value || selected.value.length === 0) return
|
|
420
|
+
|
|
421
|
+
updating.value = true
|
|
422
|
+
try {
|
|
423
|
+
const ids = selected.value.map((item) => (item as Record<string, unknown>)[resolvedDataKey])
|
|
424
|
+
const bulkPath = bulkEndpoint || 'bulk/status'
|
|
425
|
+
|
|
426
|
+
const payload = {
|
|
427
|
+
[idsField]: ids,
|
|
428
|
+
[statusField]: selectedStatus.value,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const response = (await manager.request('PATCH', bulkPath, { data: payload })) as {
|
|
432
|
+
updated?: number
|
|
433
|
+
failed?: number
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (response.updated && response.updated > 0) {
|
|
437
|
+
toast.add({
|
|
438
|
+
severity: 'success',
|
|
439
|
+
summary: 'Updated',
|
|
440
|
+
detail: `${response.updated} ${response.updated > 1 ? entityNamePlural : entityName} updated`,
|
|
441
|
+
life: 3000,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
if (response.failed && response.failed > 0) {
|
|
445
|
+
toast.add({
|
|
446
|
+
severity: 'warn',
|
|
447
|
+
summary: 'Partial Success',
|
|
448
|
+
detail: `${response.failed} ${response.failed > 1 ? entityNamePlural : entityName} failed`,
|
|
449
|
+
life: 5000,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
selected.value = []
|
|
454
|
+
selectedStatus.value = null
|
|
455
|
+
showDialog.value = false
|
|
456
|
+
loadItems({}, { force: true })
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const axiosError = error as AxiosError
|
|
459
|
+
toast.add({
|
|
460
|
+
severity: 'error',
|
|
461
|
+
summary: 'Error',
|
|
462
|
+
detail: axiosError.response?.data?.detail || 'Bulk update failed',
|
|
463
|
+
life: 5000,
|
|
464
|
+
})
|
|
465
|
+
} finally {
|
|
466
|
+
updating.value = false
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function cancel(): void {
|
|
471
|
+
showDialog.value = false
|
|
472
|
+
selectedStatus.value = null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const statusOptions = ref(options)
|
|
476
|
+
|
|
477
|
+
function setOptions(newOptions: Array<{ label: string; value: unknown }>): void {
|
|
478
|
+
statusOptions.value = newOptions
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
showDialog,
|
|
483
|
+
selectedStatus,
|
|
484
|
+
updating,
|
|
485
|
+
statusOptions,
|
|
486
|
+
dialogTitle,
|
|
487
|
+
execute,
|
|
488
|
+
cancel,
|
|
489
|
+
setOptions,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ============ CARDS ============
|
|
494
|
+
const cardsMap = ref<Map<string, CardConfig>>(new Map())
|
|
495
|
+
|
|
496
|
+
function addCard(name: string, cardConfig: Omit<CardConfig, 'name'>): void {
|
|
497
|
+
cardsMap.value.set(name, { name, ...cardConfig })
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function updateCard(name: string, cardConfig: Partial<CardConfig>): void {
|
|
501
|
+
const existing = cardsMap.value.get(name)
|
|
502
|
+
if (existing) {
|
|
503
|
+
cardsMap.value.set(name, { ...existing, ...cardConfig })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function removeCard(name: string): void {
|
|
508
|
+
cardsMap.value.delete(name)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const cards = computed(() => Array.from(cardsMap.value.values()))
|
|
512
|
+
|
|
513
|
+
// ============ FILTERS ============
|
|
514
|
+
const filtersMap = ref<Map<string, FilterConfig>>(new Map())
|
|
515
|
+
const filterValues = ref<Record<string, unknown>>(savedFilters || {})
|
|
516
|
+
|
|
517
|
+
function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
|
|
518
|
+
filtersMap.value.set(name, {
|
|
519
|
+
name,
|
|
520
|
+
type: 'select',
|
|
521
|
+
placeholder: name,
|
|
522
|
+
...filterConfig,
|
|
523
|
+
})
|
|
524
|
+
if (filterValues.value[name] === undefined) {
|
|
525
|
+
filterValues.value[name] = filterConfig.default ?? null
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function removeFilter(name: string): void {
|
|
530
|
+
filtersMap.value.delete(name)
|
|
531
|
+
delete filterValues.value[name]
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function setFilterValue(name: string, value: unknown): void {
|
|
535
|
+
filterValues.value = { ...filterValues.value, [name]: value }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function updateFilters(newValues: Record<string, unknown>): void {
|
|
539
|
+
filterValues.value = { ...filterValues.value, ...newValues }
|
|
540
|
+
onFiltersChanged()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function onFiltersChanged(): void {
|
|
544
|
+
page.value = 1
|
|
545
|
+
loadItems()
|
|
546
|
+
if (persistFilters) {
|
|
547
|
+
const toPersist: Record<string, unknown> = {}
|
|
548
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
549
|
+
const filterDef = filtersMap.value.get(name)
|
|
550
|
+
if (
|
|
551
|
+
filterDef?.persist !== false &&
|
|
552
|
+
value !== null &&
|
|
553
|
+
value !== undefined &&
|
|
554
|
+
value !== ''
|
|
555
|
+
) {
|
|
556
|
+
toPersist[name] = value
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (searchQuery.value) {
|
|
560
|
+
toPersist._search = searchQuery.value
|
|
561
|
+
}
|
|
562
|
+
setSessionFilters(filterSessionKey, toPersist)
|
|
563
|
+
}
|
|
564
|
+
if (syncUrlParams) {
|
|
565
|
+
const query = { ...route.query } as Record<string, string>
|
|
566
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
567
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
568
|
+
query[name] = String(value)
|
|
569
|
+
} else {
|
|
570
|
+
delete query[name]
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (searchQuery.value) {
|
|
574
|
+
query.search = searchQuery.value
|
|
575
|
+
} else {
|
|
576
|
+
delete query.search
|
|
577
|
+
}
|
|
578
|
+
router.replace({ query })
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function clearFilters(): void {
|
|
583
|
+
const cleared: Record<string, unknown> = {}
|
|
584
|
+
for (const [key, filterDef] of filtersMap.value.entries()) {
|
|
585
|
+
cleared[key] = filterDef.default ?? null
|
|
586
|
+
}
|
|
587
|
+
filterValues.value = cleared
|
|
588
|
+
searchQuery.value = ''
|
|
589
|
+
if (persistFilters) {
|
|
590
|
+
clearSessionFilters(filterSessionKey)
|
|
591
|
+
}
|
|
592
|
+
if (syncUrlParams) {
|
|
593
|
+
const query = { ...route.query } as Record<string, string>
|
|
594
|
+
for (const key of filtersMap.value.keys()) {
|
|
595
|
+
delete query[key]
|
|
596
|
+
}
|
|
597
|
+
delete query.search
|
|
598
|
+
router.replace({ query })
|
|
599
|
+
}
|
|
600
|
+
page.value = 1
|
|
601
|
+
loadItems()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const filters = computed(() => Array.from(filtersMap.value.values()))
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Check if a filter is at its default value
|
|
608
|
+
* Used for styling: default = blue (info), modified = orange (warning)
|
|
609
|
+
*/
|
|
610
|
+
function isFilterAtDefault(name: string): boolean {
|
|
611
|
+
const filterDef = filtersMap.value.get(name)
|
|
612
|
+
if (!filterDef) return true
|
|
613
|
+
const currentValue = filterValues.value[name]
|
|
614
|
+
const defaultValue = filterDef.default ?? null
|
|
615
|
+
return currentValue === defaultValue
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Check if any filter is NOT at its default value (or search is active)
|
|
620
|
+
* Useful to show a "clear filters" button only when needed
|
|
621
|
+
*/
|
|
622
|
+
const hasActiveFilters = computed(() => {
|
|
623
|
+
if (searchQuery.value) return true
|
|
624
|
+
for (const [name, filterDef] of filtersMap.value.entries()) {
|
|
625
|
+
const currentValue = filterValues.value[name]
|
|
626
|
+
const defaultValue = filterDef.default ?? null
|
|
627
|
+
if (currentValue !== defaultValue) return true
|
|
628
|
+
}
|
|
629
|
+
return false
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// ============ AUTO-LOAD FROM REGISTRY ============
|
|
633
|
+
|
|
634
|
+
function initFromRegistry(): void {
|
|
635
|
+
if (!autoLoadFilters) return
|
|
636
|
+
|
|
637
|
+
const entityConfig = entityFilters[entityName]
|
|
638
|
+
if (!entityConfig) return
|
|
639
|
+
|
|
640
|
+
if (entityConfig.search) {
|
|
641
|
+
setSearch(entityConfig.search)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (entityConfig.filters) {
|
|
645
|
+
for (const filterDef of entityConfig.filters) {
|
|
646
|
+
addFilter(filterDef.name, filterDef)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function loadFilterOptions(): Promise<void> {
|
|
652
|
+
// Process filters configured directly via addFilter() (smart filter modes)
|
|
653
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
654
|
+
if (filterDef.options && filterDef.options.length > 1) continue
|
|
655
|
+
if (filterDef.optionsFromCache) continue
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
let rawOptions: Array<{ label: string; value: unknown }> | null = null
|
|
659
|
+
|
|
660
|
+
// Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
|
|
661
|
+
if (filterDef.optionsEntity) {
|
|
662
|
+
const filterQuery = new FilterQuery({
|
|
663
|
+
source: 'entity',
|
|
664
|
+
entity: filterDef.optionsEntity,
|
|
665
|
+
label: filterDef.optionLabel || 'name',
|
|
666
|
+
value: filterDef.optionValue || 'id',
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
rawOptions = (await filterQuery.getOptions(orchestrator)) as Array<{
|
|
670
|
+
label: string
|
|
671
|
+
value: unknown
|
|
672
|
+
}>
|
|
673
|
+
filterDef._filterQuery = filterQuery
|
|
674
|
+
}
|
|
675
|
+
// Mode 2: optionsEndpoint - fetch from API endpoint
|
|
676
|
+
else if (filterDef.optionsEndpoint) {
|
|
677
|
+
const endpoint =
|
|
678
|
+
filterDef.optionsEndpoint === true
|
|
679
|
+
? `distinct/${filterName}`
|
|
680
|
+
: filterDef.optionsEndpoint
|
|
681
|
+
const response = await manager.request('GET', endpoint as string)
|
|
682
|
+
const data = Array.isArray(response)
|
|
683
|
+
? response
|
|
684
|
+
: ((response as Record<string, unknown>)?.items as unknown[]) || []
|
|
685
|
+
rawOptions = data.map((opt) => {
|
|
686
|
+
if (typeof opt === 'object' && opt !== null) {
|
|
687
|
+
const o = opt as Record<string, unknown>
|
|
688
|
+
return {
|
|
689
|
+
label: (o.label || o.name || String(o.value ?? o.id)) as string,
|
|
690
|
+
value: o.value ?? o.id,
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { label: snakeToTitle(String(opt)), value: opt }
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (rawOptions !== null) {
|
|
698
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
699
|
+
let shouldCache = cacheOptions === true
|
|
700
|
+
|
|
701
|
+
if (cacheOptions === 'auto') {
|
|
702
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const componentType =
|
|
706
|
+
filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
|
|
707
|
+
const allLabel =
|
|
708
|
+
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
709
|
+
let finalOptions = [{ label: allLabel, value: null as unknown }, ...rawOptions]
|
|
710
|
+
|
|
711
|
+
if (typeof filterDef.processor === 'function') {
|
|
712
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const updatedFilter: FilterConfig = {
|
|
716
|
+
...filterDef,
|
|
717
|
+
options: finalOptions,
|
|
718
|
+
type: componentType,
|
|
719
|
+
_cacheOptions: shouldCache,
|
|
720
|
+
_optionsLoaded: shouldCache,
|
|
721
|
+
}
|
|
722
|
+
delete updatedFilter.optionLabel
|
|
723
|
+
delete updatedFilter.optionValue
|
|
724
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
725
|
+
}
|
|
726
|
+
} catch (error) {
|
|
727
|
+
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Invoke filter:alter hooks after all options are loaded
|
|
732
|
+
await invokeFilterAlterHook()
|
|
733
|
+
|
|
734
|
+
// Trigger Vue reactivity
|
|
735
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function updateCacheBasedFilters(): Promise<void> {
|
|
739
|
+
if (items.value.length === 0) return
|
|
740
|
+
|
|
741
|
+
let hasChanges = false
|
|
742
|
+
|
|
743
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
744
|
+
if (!filterDef.optionsFromCache) continue
|
|
745
|
+
if (filterDef._optionsLoaded) continue
|
|
746
|
+
// Skip if filter has an explicit query property (advanced usage)
|
|
747
|
+
if (filterDef.query) continue
|
|
748
|
+
|
|
749
|
+
const currentValue = filterValues.value[filterName]
|
|
750
|
+
if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const fieldName =
|
|
755
|
+
typeof filterDef.optionsFromCache === 'string' ? filterDef.optionsFromCache : filterName
|
|
756
|
+
|
|
757
|
+
const filterQuery = new FilterQuery({
|
|
758
|
+
source: 'field',
|
|
759
|
+
field: fieldName,
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
// Provide a minimal mock manager with cached data for field-based filtering
|
|
763
|
+
filterQuery.setParentManager({
|
|
764
|
+
_cache: items.value,
|
|
765
|
+
list: async () => ({ items: items.value }),
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
const rawOptions = (await filterQuery.getOptions()) as Array<{ label: string; value: unknown }>
|
|
769
|
+
|
|
770
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
771
|
+
let shouldCache = true
|
|
772
|
+
|
|
773
|
+
if (cacheOptions === 'auto') {
|
|
774
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
775
|
+
} else if (cacheOptions === false) {
|
|
776
|
+
shouldCache = false
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const componentType =
|
|
780
|
+
filterDef.component ||
|
|
781
|
+
(rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
|
|
782
|
+
|
|
783
|
+
const allLabel =
|
|
784
|
+
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
785
|
+
let finalOptions = [
|
|
786
|
+
{ label: allLabel, value: null as unknown },
|
|
787
|
+
...rawOptions.map((opt) => ({
|
|
788
|
+
label: snakeToTitle(String(opt.label)),
|
|
789
|
+
value: opt.value,
|
|
790
|
+
})),
|
|
791
|
+
]
|
|
792
|
+
|
|
793
|
+
if (typeof filterDef.processor === 'function') {
|
|
794
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const updatedFilter: FilterConfig = {
|
|
798
|
+
...filterDef,
|
|
799
|
+
options: finalOptions,
|
|
800
|
+
type: componentType,
|
|
801
|
+
_cacheOptions: shouldCache,
|
|
802
|
+
_optionsLoaded: true,
|
|
803
|
+
_filterQuery: filterQuery,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
807
|
+
hasChanges = true
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (hasChanges) {
|
|
811
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function restoreFilters(): void {
|
|
816
|
+
for (const key of filtersMap.value.keys()) {
|
|
817
|
+
if (route.query[key] !== undefined) {
|
|
818
|
+
let value: unknown = route.query[key]
|
|
819
|
+
if (value === 'true') value = true
|
|
820
|
+
else if (value === 'false') value = false
|
|
821
|
+
else if (value === 'null') value = null
|
|
822
|
+
else if (!isNaN(Number(value)) && value !== '') value = Number(value)
|
|
823
|
+
filterValues.value[key] = value
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (route.query.search) {
|
|
827
|
+
searchQuery.value = route.query.search as string
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ============ ACTIONS ============
|
|
832
|
+
const actionsMap = ref<Map<string, ActionConfig>>(new Map())
|
|
833
|
+
|
|
834
|
+
function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
|
|
835
|
+
actionsMap.value.set(name, {
|
|
836
|
+
name,
|
|
837
|
+
severity: 'secondary',
|
|
838
|
+
...actionConfig,
|
|
839
|
+
} as ActionConfig)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function removeAction(name: string): void {
|
|
843
|
+
actionsMap.value.delete(name)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function resolveValue<T>(value: T | ((row: unknown) => T), row: unknown): T {
|
|
847
|
+
return typeof value === 'function' ? (value as (row: unknown) => T)(row) : value
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function getActions(row: unknown): ResolvedAction[] {
|
|
851
|
+
const actions: ResolvedAction[] = []
|
|
852
|
+
for (const [, action] of actionsMap.value) {
|
|
853
|
+
if (action.visible && !action.visible(row)) continue
|
|
854
|
+
actions.push({
|
|
855
|
+
name: action.name,
|
|
856
|
+
icon: action.icon ? resolveValue(action.icon, row) : undefined,
|
|
857
|
+
tooltip: action.tooltip ? resolveValue(action.tooltip, row) : undefined,
|
|
858
|
+
severity: resolveValue(action.severity || 'secondary', row),
|
|
859
|
+
isDisabled: action.disabled ? action.disabled(row) : false,
|
|
860
|
+
handler: () => action.onClick(row),
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
return actions
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ============ SEARCH ============
|
|
867
|
+
function setSearch(searchCfg: Partial<SearchConfig>): void {
|
|
868
|
+
searchConfig.value = { ...searchConfig.value, ...searchCfg }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ============ CACHE MODE ============
|
|
872
|
+
const fromCache = ref(false)
|
|
873
|
+
|
|
874
|
+
const filteredItems = computed(() => {
|
|
875
|
+
let result = [...items.value]
|
|
876
|
+
|
|
877
|
+
// local_filter: post-filter hack for edge cases
|
|
878
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
879
|
+
if (value === null || value === undefined || value === '') continue
|
|
880
|
+
const filterDef = filtersMap.value.get(name)
|
|
881
|
+
if (typeof filterDef?.local_filter !== 'function') continue
|
|
882
|
+
result = result.filter((item) => filterDef.local_filter!(item, value))
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// local_search: post-filter hack for external lookups
|
|
886
|
+
if (searchQuery.value && typeof searchConfig.value.local_search === 'function') {
|
|
887
|
+
const query = searchQuery.value.toLowerCase()
|
|
888
|
+
result = result.filter((item) => searchConfig.value.local_search!(item, query))
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return result
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
const displayItems = computed(() => filteredItems.value)
|
|
895
|
+
|
|
896
|
+
// ============ LOADING ============
|
|
897
|
+
let filterOptionsLoaded = false
|
|
898
|
+
|
|
899
|
+
async function loadItems(
|
|
900
|
+
extraParams: Record<string, unknown> = {},
|
|
901
|
+
{ force = false } = {}
|
|
902
|
+
): Promise<void> {
|
|
903
|
+
if (!manager) return
|
|
904
|
+
|
|
905
|
+
if (force && manager.invalidateCache) {
|
|
906
|
+
manager.invalidateCache()
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
loading.value = true
|
|
910
|
+
try {
|
|
911
|
+
let params: Record<string, unknown> = { ...extraParams }
|
|
912
|
+
|
|
913
|
+
params.page = page.value
|
|
914
|
+
params.page_size = pageSize.value
|
|
915
|
+
if (sortField.value) {
|
|
916
|
+
params.sort_by = sortField.value
|
|
917
|
+
params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (searchQuery.value && typeof searchConfig.value.local_search !== 'function') {
|
|
921
|
+
params.search = searchQuery.value
|
|
922
|
+
if (searchConfig.value.fields && searchConfig.value.fields.length > 0) {
|
|
923
|
+
params.searchFields = searchConfig.value.fields
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const filtersObj: Record<string, unknown> = {}
|
|
928
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
929
|
+
if (value === null || value === undefined || value === '') continue
|
|
930
|
+
const filterDef = filtersMap.value.get(name)
|
|
931
|
+
if (typeof filterDef?.local_filter === 'function') continue
|
|
932
|
+
|
|
933
|
+
if (typeof filterDef?.toQuery === 'function') {
|
|
934
|
+
const query = filterDef.toQuery(value)
|
|
935
|
+
if (query && typeof query === 'object') {
|
|
936
|
+
Object.assign(filtersObj, query)
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
filtersObj[name] = value
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (parentConfigComputed.value?.foreignKey && parentConfigComputed.value?.param) {
|
|
944
|
+
const parentIdFromRoute = route.params[parentConfigComputed.value.param]
|
|
945
|
+
if (parentIdFromRoute) {
|
|
946
|
+
filtersObj[parentConfigComputed.value.foreignKey] = parentIdFromRoute
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (Object.keys(filtersObj).length > 0) {
|
|
951
|
+
params.filters = filtersObj
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (onBeforeLoad) {
|
|
955
|
+
params = onBeforeLoad(params) || params
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const response = manager.query
|
|
959
|
+
? await manager.query(params, { routingContext: entityContext.value })
|
|
960
|
+
: await manager.list(params, entityContext.value)
|
|
961
|
+
|
|
962
|
+
fromCache.value = response.fromCache || false
|
|
963
|
+
|
|
964
|
+
let processedData: { items: T[]; total: number }
|
|
965
|
+
if (transformResponse) {
|
|
966
|
+
processedData = transformResponse(response as ListResponse<T>)
|
|
967
|
+
} else {
|
|
968
|
+
processedData = {
|
|
969
|
+
items: (response.items || []) as T[],
|
|
970
|
+
total: response.total || response.items?.length || 0,
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
items.value = processedData.items
|
|
975
|
+
totalRecords.value = processedData.total
|
|
976
|
+
|
|
977
|
+
if (onAfterLoad) {
|
|
978
|
+
onAfterLoad(response as ListResponse<T>, processedData)
|
|
979
|
+
}
|
|
980
|
+
} catch (error) {
|
|
981
|
+
toast.add({
|
|
982
|
+
severity: 'error',
|
|
983
|
+
summary: 'Error',
|
|
984
|
+
detail: `Failed to load ${entityNamePlural}`,
|
|
985
|
+
life: 5000,
|
|
986
|
+
})
|
|
987
|
+
console.error('Load error:', error)
|
|
988
|
+
} finally {
|
|
989
|
+
loading.value = false
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ============ PAGINATION & SORTING ============
|
|
994
|
+
function onPage(event: { page: number; rows: number }): void {
|
|
995
|
+
page.value = event.page + 1
|
|
996
|
+
pageSize.value = event.rows
|
|
997
|
+
persistPageSize(event.rows)
|
|
998
|
+
loadItems()
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function onSort(event: { sortField: string; sortOrder: number }): void {
|
|
1002
|
+
sortField.value = event.sortField
|
|
1003
|
+
sortOrder.value = event.sortOrder as 1 | -1
|
|
1004
|
+
loadItems()
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ============ NAVIGATION ============
|
|
1008
|
+
|
|
1009
|
+
function findCreateRoute(): { name: string; params?: Record<string, unknown> } {
|
|
1010
|
+
const parentConfigVal = route.meta?.parent as ParentConfig | undefined
|
|
1011
|
+
|
|
1012
|
+
if (parentConfigVal) {
|
|
1013
|
+
const currentMatched = route.matched[route.matched.length - 1]
|
|
1014
|
+
if (currentMatched) {
|
|
1015
|
+
const createPath = `${currentMatched.path}/create`
|
|
1016
|
+
const createRoute = router.getRoutes().find((r) => r.path === createPath)
|
|
1017
|
+
if (createRoute?.name) {
|
|
1018
|
+
return { name: createRoute.name as string, params: route.params as Record<string, unknown> }
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const currentName = route.name as string
|
|
1023
|
+
if (currentName && currentName.endsWith('s')) {
|
|
1024
|
+
const singularName = currentName.slice(0, -1)
|
|
1025
|
+
const createRouteName = `${singularName}-create`
|
|
1026
|
+
if (router.hasRoute(createRouteName)) {
|
|
1027
|
+
return { name: createRouteName, params: route.params as Record<string, unknown> }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return { name: `${routePrefix}-create` }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function goToCreate(): void {
|
|
1036
|
+
router.push(findCreateRoute() as { name: string })
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function goToEdit(item: unknown): void {
|
|
1040
|
+
router.push({
|
|
1041
|
+
name: `${routePrefix}-edit`,
|
|
1042
|
+
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
1043
|
+
})
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function goToShow(item: unknown): void {
|
|
1047
|
+
router.push({
|
|
1048
|
+
name: `${routePrefix}-show`,
|
|
1049
|
+
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ============ MOBILE ROW TAP ============
|
|
1054
|
+
|
|
1055
|
+
function getPrimaryRowAction(): 'edit' | 'show' | null {
|
|
1056
|
+
const editRouteName = `${routePrefix}-edit`
|
|
1057
|
+
const showRouteName = `${routePrefix}-show`
|
|
1058
|
+
|
|
1059
|
+
if (router.hasRoute(editRouteName)) {
|
|
1060
|
+
return 'edit'
|
|
1061
|
+
}
|
|
1062
|
+
if (router.hasRoute(showRouteName)) {
|
|
1063
|
+
return 'show'
|
|
1064
|
+
}
|
|
1065
|
+
return null
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function onRowClick(item: unknown, originalEvent: Event): void {
|
|
1069
|
+
if (!isMobile.value) return
|
|
1070
|
+
if (hasBulkActions.value && selected.value.length > 0) return
|
|
1071
|
+
|
|
1072
|
+
const target = originalEvent?.target as HTMLElement
|
|
1073
|
+
if (target?.closest('button, a, .p-button, .table-actions, input, .p-checkbox')) {
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const action = getPrimaryRowAction()
|
|
1078
|
+
if (action === 'edit') {
|
|
1079
|
+
goToEdit(item)
|
|
1080
|
+
} else if (action === 'show') {
|
|
1081
|
+
goToShow(item)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const hasRowTapAction = computed(() => isMobile.value && getPrimaryRowAction() !== null)
|
|
1086
|
+
|
|
1087
|
+
// ============ DELETE ============
|
|
1088
|
+
async function deleteItem(item: unknown, labelField = 'name'): Promise<void> {
|
|
1089
|
+
try {
|
|
1090
|
+
const itemRecord = item as Record<string, unknown>
|
|
1091
|
+
await manager.delete(itemRecord[resolvedDataKey] as string | number)
|
|
1092
|
+
toast.add({
|
|
1093
|
+
severity: 'success',
|
|
1094
|
+
summary: 'Deleted',
|
|
1095
|
+
detail: `${entityName} "${itemRecord[labelField] || itemRecord[resolvedDataKey]}" deleted`,
|
|
1096
|
+
life: 3000,
|
|
1097
|
+
})
|
|
1098
|
+
loadItems({}, { force: true })
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
const axiosError = error as AxiosError
|
|
1101
|
+
toast.add({
|
|
1102
|
+
severity: 'error',
|
|
1103
|
+
summary: 'Error',
|
|
1104
|
+
detail: axiosError.response?.data?.detail || `Failed to delete ${entityName}`,
|
|
1105
|
+
life: 5000,
|
|
1106
|
+
})
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function confirmDelete(item: unknown, labelField = 'name'): void {
|
|
1111
|
+
const itemRecord = item as Record<string, unknown>
|
|
1112
|
+
confirm.require({
|
|
1113
|
+
message: `Delete ${entityName} "${itemRecord[labelField] || itemRecord[resolvedDataKey]}"?`,
|
|
1114
|
+
header: 'Confirm Delete',
|
|
1115
|
+
icon: 'pi pi-exclamation-triangle',
|
|
1116
|
+
acceptClass: 'p-button-danger',
|
|
1117
|
+
accept: () => deleteItem(item, labelField),
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ============ BULK OPERATIONS ============
|
|
1122
|
+
const hasSelection = computed(() => selected.value.length > 0)
|
|
1123
|
+
const selectionCount = computed(() => selected.value.length)
|
|
1124
|
+
|
|
1125
|
+
async function bulkDelete(): Promise<void> {
|
|
1126
|
+
deleting.value = true
|
|
1127
|
+
let successCount = 0
|
|
1128
|
+
let errorCount = 0
|
|
1129
|
+
|
|
1130
|
+
for (const item of [...selected.value]) {
|
|
1131
|
+
try {
|
|
1132
|
+
const itemRecord = item as Record<string, unknown>
|
|
1133
|
+
await manager.delete(itemRecord[resolvedDataKey] as string | number)
|
|
1134
|
+
successCount++
|
|
1135
|
+
} catch {
|
|
1136
|
+
errorCount++
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
deleting.value = false
|
|
1141
|
+
selected.value = []
|
|
1142
|
+
|
|
1143
|
+
if (successCount > 0) {
|
|
1144
|
+
toast.add({
|
|
1145
|
+
severity: 'success',
|
|
1146
|
+
summary: 'Deleted',
|
|
1147
|
+
detail: `${successCount} ${successCount > 1 ? entityNamePlural : entityName} deleted`,
|
|
1148
|
+
life: 3000,
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
if (errorCount > 0) {
|
|
1152
|
+
toast.add({
|
|
1153
|
+
severity: 'error',
|
|
1154
|
+
summary: 'Error',
|
|
1155
|
+
detail: `Failed to delete ${errorCount} ${errorCount > 1 ? entityNamePlural : entityName}`,
|
|
1156
|
+
life: 5000,
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
loadItems({}, { force: true })
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function confirmBulkDelete(): void {
|
|
1164
|
+
const count = selected.value.length
|
|
1165
|
+
confirm.require({
|
|
1166
|
+
message: `Delete ${count} ${count > 1 ? entityNamePlural : entityName}?`,
|
|
1167
|
+
header: 'Confirm Bulk Delete',
|
|
1168
|
+
icon: 'pi pi-exclamation-triangle',
|
|
1169
|
+
acceptClass: 'p-button-danger',
|
|
1170
|
+
accept: bulkDelete,
|
|
1171
|
+
})
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ============ WATCHERS ============
|
|
1175
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
1176
|
+
watch(searchQuery, () => {
|
|
1177
|
+
if (searchTimeout) clearTimeout(searchTimeout)
|
|
1178
|
+
searchTimeout = setTimeout(() => {
|
|
1179
|
+
onFiltersChanged()
|
|
1180
|
+
}, searchConfig.value.debounce || 300)
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
watch(items, async () => {
|
|
1184
|
+
try {
|
|
1185
|
+
await updateCacheBasedFilters()
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
console.warn('[qdadm] Failed to update cache-based filters:', error)
|
|
1188
|
+
}
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
// ============ HOOKS ============
|
|
1192
|
+
|
|
1193
|
+
async function invokeListAlterHook(): Promise<void> {
|
|
1194
|
+
if (!hooks) return
|
|
1195
|
+
|
|
1196
|
+
const configSnapshot = {
|
|
1197
|
+
entity,
|
|
1198
|
+
columns: Array.from(columnsMap.value.values()),
|
|
1199
|
+
filters: Array.from(filtersMap.value.values()),
|
|
1200
|
+
actions: Array.from(actionsMap.value.values()),
|
|
1201
|
+
headerActions: Array.from(headerActionsMap.value.values()),
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Include entity context in the snapshot for hooks
|
|
1205
|
+
const configWithContext = { ...configSnapshot, entity, manager }
|
|
1206
|
+
|
|
1207
|
+
let alteredConfig = await hooks.alter('list:alter', configWithContext)
|
|
1208
|
+
|
|
1209
|
+
const entityHookName = `${entity}:list:alter`
|
|
1210
|
+
if (hooks.hasHook(entityHookName)) {
|
|
1211
|
+
alteredConfig = await hooks.alter(entityHookName, alteredConfig)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
applyAlteredConfig(alteredConfig)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
async function invokeFilterAlterHook(): Promise<void> {
|
|
1218
|
+
if (!hooks) return
|
|
1219
|
+
|
|
1220
|
+
const filterSnapshot = {
|
|
1221
|
+
entity,
|
|
1222
|
+
filters: Array.from(filtersMap.value.values()),
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
let alteredFilters = (await hooks.alter('filter:alter', filterSnapshot)) as typeof filterSnapshot
|
|
1226
|
+
|
|
1227
|
+
const entityHookName = `${entity}:filter:alter`
|
|
1228
|
+
if (hooks.hasHook(entityHookName)) {
|
|
1229
|
+
alteredFilters = (await hooks.alter(entityHookName, alteredFilters)) as typeof filterSnapshot
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (alteredFilters.filters) {
|
|
1233
|
+
filtersMap.value.clear()
|
|
1234
|
+
for (const filter of alteredFilters.filters) {
|
|
1235
|
+
filtersMap.value.set(filter.name, filter)
|
|
1236
|
+
if (filterValues.value[filter.name] === undefined) {
|
|
1237
|
+
filterValues.value[filter.name] = filter.default ?? null
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function applyAlteredConfig(alteredConfig: {
|
|
1244
|
+
columns?: ColumnConfig[]
|
|
1245
|
+
filters?: FilterConfig[]
|
|
1246
|
+
actions?: ActionConfig[]
|
|
1247
|
+
headerActions?: HeaderActionConfig[]
|
|
1248
|
+
}): void {
|
|
1249
|
+
if (alteredConfig.columns) {
|
|
1250
|
+
columnsMap.value.clear()
|
|
1251
|
+
for (const col of alteredConfig.columns) {
|
|
1252
|
+
columnsMap.value.set(col.field, col)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (alteredConfig.filters) {
|
|
1257
|
+
filtersMap.value.clear()
|
|
1258
|
+
for (const filter of alteredConfig.filters) {
|
|
1259
|
+
filtersMap.value.set(filter.name, filter)
|
|
1260
|
+
if (filterValues.value[filter.name] === undefined) {
|
|
1261
|
+
filterValues.value[filter.name] = filter.default ?? null
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (alteredConfig.actions) {
|
|
1267
|
+
actionsMap.value.clear()
|
|
1268
|
+
for (const action of alteredConfig.actions) {
|
|
1269
|
+
actionsMap.value.set(action.name, action)
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (alteredConfig.headerActions) {
|
|
1274
|
+
headerActionsMap.value.clear()
|
|
1275
|
+
for (const action of alteredConfig.headerActions) {
|
|
1276
|
+
headerActionsMap.value.set(action.name, action)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ============ LIFECYCLE ============
|
|
1282
|
+
initFromRegistry()
|
|
1283
|
+
|
|
1284
|
+
function handleResize(): void {
|
|
1285
|
+
isMobile.value = window.innerWidth < 768
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
onMounted(async () => {
|
|
1289
|
+
window.addEventListener('resize', handleResize)
|
|
1290
|
+
|
|
1291
|
+
restoreFilters()
|
|
1292
|
+
|
|
1293
|
+
if (!filterOptionsLoaded) {
|
|
1294
|
+
filterOptionsLoaded = true
|
|
1295
|
+
await loadFilterOptions()
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
await invokeListAlterHook()
|
|
1299
|
+
|
|
1300
|
+
if (loadOnMount && manager) {
|
|
1301
|
+
loadItems()
|
|
1302
|
+
}
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
onUnmounted(() => {
|
|
1306
|
+
window.removeEventListener('resize', handleResize)
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
// ============ UTILITIES ============
|
|
1310
|
+
function formatDate(
|
|
1311
|
+
dateStr: string | null | undefined,
|
|
1312
|
+
options: Intl.DateTimeFormatOptions = {}
|
|
1313
|
+
): string {
|
|
1314
|
+
if (!dateStr) return '-'
|
|
1315
|
+
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
1316
|
+
day: '2-digit',
|
|
1317
|
+
month: '2-digit',
|
|
1318
|
+
year: 'numeric',
|
|
1319
|
+
...options,
|
|
1320
|
+
}
|
|
1321
|
+
return new Date(dateStr).toLocaleDateString('fr-FR', defaultOptions)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ============ STANDARD ACTIONS ============
|
|
1325
|
+
|
|
1326
|
+
function addViewAction(options: Partial<ActionConfig> = {}): void {
|
|
1327
|
+
addAction('view', {
|
|
1328
|
+
icon: 'pi pi-eye',
|
|
1329
|
+
tooltip: 'View',
|
|
1330
|
+
onClick: options.onClick || goToShow,
|
|
1331
|
+
...options,
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function addEditAction(options: Partial<ActionConfig> = {}): void {
|
|
1336
|
+
addAction('edit', {
|
|
1337
|
+
icon: 'pi pi-pencil',
|
|
1338
|
+
tooltip: 'Edit',
|
|
1339
|
+
onClick: options.onClick || goToEdit,
|
|
1340
|
+
visible: (row) => manager.canUpdate(row),
|
|
1341
|
+
...options,
|
|
1342
|
+
})
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function addDeleteAction(options: Partial<ActionConfig> & { labelField?: string } = {}): void {
|
|
1346
|
+
const labelField = options.labelField || 'name'
|
|
1347
|
+
addAction('delete', {
|
|
1348
|
+
icon: 'pi pi-trash',
|
|
1349
|
+
tooltip: 'Delete',
|
|
1350
|
+
severity: 'danger',
|
|
1351
|
+
onClick: (row) => confirmDelete(row, labelField),
|
|
1352
|
+
visible: (row) => manager.canDelete(row),
|
|
1353
|
+
...options,
|
|
1354
|
+
})
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ============ BULK ACTIONS DETECTION ============
|
|
1358
|
+
|
|
1359
|
+
const hasBulkActions = computed(() => {
|
|
1360
|
+
for (const [, action] of headerActionsMap.value) {
|
|
1361
|
+
if (action.visible && typeof action.visible === 'function') {
|
|
1362
|
+
const wouldShow = action.visible({ hasSelection: true, selectionCount: 1, deleting: false })
|
|
1363
|
+
const wouldHide = action.visible({
|
|
1364
|
+
hasSelection: false,
|
|
1365
|
+
selectionCount: 0,
|
|
1366
|
+
deleting: false,
|
|
1367
|
+
})
|
|
1368
|
+
if (wouldShow && !wouldHide) {
|
|
1369
|
+
return true
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return false
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
// ============ LIST PAGE PROPS ============
|
|
1377
|
+
|
|
1378
|
+
const listProps = computed<ListPageProps>(() => ({
|
|
1379
|
+
title: manager.labelPlural,
|
|
1380
|
+
headerActions: headerActions.value,
|
|
1381
|
+
cards: cards.value,
|
|
1382
|
+
columns: columns.value,
|
|
1383
|
+
items: displayItems.value,
|
|
1384
|
+
loading: loading.value,
|
|
1385
|
+
dataKey: resolvedDataKey,
|
|
1386
|
+
selected: selected.value,
|
|
1387
|
+
selectable: hasBulkActions.value,
|
|
1388
|
+
lazy: true,
|
|
1389
|
+
totalRecords: totalRecords.value,
|
|
1390
|
+
rows: pageSize.value,
|
|
1391
|
+
rowsPerPageOptions,
|
|
1392
|
+
sortField: sortField.value,
|
|
1393
|
+
sortOrder: sortOrder.value,
|
|
1394
|
+
searchQuery: searchQuery.value,
|
|
1395
|
+
searchPlaceholder: searchConfig.value.placeholder || 'Search...',
|
|
1396
|
+
filters: filters.value,
|
|
1397
|
+
filterValues: filterValues.value,
|
|
1398
|
+
isFilterAtDefault,
|
|
1399
|
+
getActions,
|
|
1400
|
+
hasRowTapAction: hasRowTapAction.value,
|
|
1401
|
+
}))
|
|
1402
|
+
|
|
1403
|
+
const listEvents: ListPageEvents = {
|
|
1404
|
+
'update:selected': (value) => {
|
|
1405
|
+
selected.value = value as T[]
|
|
1406
|
+
},
|
|
1407
|
+
'update:searchQuery': (value) => {
|
|
1408
|
+
searchQuery.value = value
|
|
1409
|
+
},
|
|
1410
|
+
'update:filterValues': updateFilters,
|
|
1411
|
+
page: onPage,
|
|
1412
|
+
sort: onSort,
|
|
1413
|
+
'row-click': onRowClick,
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
return {
|
|
1417
|
+
// Manager access
|
|
1418
|
+
manager,
|
|
1419
|
+
|
|
1420
|
+
// Parent chain
|
|
1421
|
+
parentConfig: parentConfigComputed,
|
|
1422
|
+
parentId,
|
|
1423
|
+
parentData,
|
|
1424
|
+
parentLoading,
|
|
1425
|
+
parentChain,
|
|
1426
|
+
parentPage,
|
|
1427
|
+
|
|
1428
|
+
// State
|
|
1429
|
+
items,
|
|
1430
|
+
displayItems,
|
|
1431
|
+
loading,
|
|
1432
|
+
selected,
|
|
1433
|
+
deleting,
|
|
1434
|
+
|
|
1435
|
+
// Pagination
|
|
1436
|
+
page,
|
|
1437
|
+
pageSize,
|
|
1438
|
+
totalRecords,
|
|
1439
|
+
rowsPerPageOptions,
|
|
1440
|
+
sortField,
|
|
1441
|
+
sortOrder,
|
|
1442
|
+
onPage,
|
|
1443
|
+
onSort,
|
|
1444
|
+
|
|
1445
|
+
// Search
|
|
1446
|
+
searchQuery,
|
|
1447
|
+
searchConfig,
|
|
1448
|
+
setSearch,
|
|
1449
|
+
|
|
1450
|
+
// Columns
|
|
1451
|
+
columns,
|
|
1452
|
+
addColumn,
|
|
1453
|
+
removeColumn,
|
|
1454
|
+
updateColumn,
|
|
1455
|
+
|
|
1456
|
+
// Header Actions
|
|
1457
|
+
headerActions,
|
|
1458
|
+
addHeaderAction,
|
|
1459
|
+
removeHeaderAction,
|
|
1460
|
+
getHeaderActions,
|
|
1461
|
+
addCreateAction,
|
|
1462
|
+
addBulkDeleteAction,
|
|
1463
|
+
addBulkStatusAction,
|
|
1464
|
+
hasBulkActions,
|
|
1465
|
+
|
|
1466
|
+
// Cards
|
|
1467
|
+
cards,
|
|
1468
|
+
addCard,
|
|
1469
|
+
updateCard,
|
|
1470
|
+
removeCard,
|
|
1471
|
+
|
|
1472
|
+
// Filters
|
|
1473
|
+
filters,
|
|
1474
|
+
filterValues,
|
|
1475
|
+
filteredItems,
|
|
1476
|
+
fromCache,
|
|
1477
|
+
addFilter,
|
|
1478
|
+
removeFilter,
|
|
1479
|
+
setFilterValue,
|
|
1480
|
+
updateFilters,
|
|
1481
|
+
clearFilters,
|
|
1482
|
+
isFilterAtDefault,
|
|
1483
|
+
hasActiveFilters,
|
|
1484
|
+
loadFilterOptions,
|
|
1485
|
+
initFromRegistry,
|
|
1486
|
+
restoreFilters,
|
|
1487
|
+
|
|
1488
|
+
// Actions
|
|
1489
|
+
addAction,
|
|
1490
|
+
removeAction,
|
|
1491
|
+
getActions,
|
|
1492
|
+
getRowActions,
|
|
1493
|
+
addViewAction,
|
|
1494
|
+
addEditAction,
|
|
1495
|
+
addDeleteAction,
|
|
1496
|
+
|
|
1497
|
+
// Permissions
|
|
1498
|
+
canCreate,
|
|
1499
|
+
canDelete,
|
|
1500
|
+
canEditRow,
|
|
1501
|
+
canDeleteRow,
|
|
1502
|
+
|
|
1503
|
+
// Data
|
|
1504
|
+
loadItems,
|
|
1505
|
+
|
|
1506
|
+
// Navigation
|
|
1507
|
+
goToCreate,
|
|
1508
|
+
goToEdit,
|
|
1509
|
+
goToShow,
|
|
1510
|
+
|
|
1511
|
+
// Mobile row tap
|
|
1512
|
+
isMobile,
|
|
1513
|
+
hasRowTapAction,
|
|
1514
|
+
getPrimaryRowAction,
|
|
1515
|
+
onRowClick,
|
|
1516
|
+
|
|
1517
|
+
// Delete
|
|
1518
|
+
deleteItem,
|
|
1519
|
+
confirmDelete,
|
|
1520
|
+
bulkDelete,
|
|
1521
|
+
confirmBulkDelete,
|
|
1522
|
+
hasSelection,
|
|
1523
|
+
selectionCount,
|
|
1524
|
+
|
|
1525
|
+
// Utilities
|
|
1526
|
+
formatDate,
|
|
1527
|
+
toast,
|
|
1528
|
+
confirm,
|
|
1529
|
+
router,
|
|
1530
|
+
|
|
1531
|
+
// ListPage integration
|
|
1532
|
+
props: listProps,
|
|
1533
|
+
events: listEvents,
|
|
1534
|
+
}
|
|
1535
|
+
}
|