@ngbplatform/ui 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +44 -0
- package/package.json +61 -0
- package/public/favicon.svg +6 -0
- package/public/silent-check-sso.html +8 -0
- package/src/assets/ngb_logo.svg +17 -0
- package/src/assets/ngb_logo_color.svg +17 -0
- package/src/index.ts +1023 -0
- package/src/ngb/README.md +27 -0
- package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
- package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
- package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
- package/src/ngb/accounting/api.ts +79 -0
- package/src/ngb/accounting/generalJournalEntry.ts +127 -0
- package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
- package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
- package/src/ngb/accounting/navigation.ts +83 -0
- package/src/ngb/accounting/periodClosing.ts +75 -0
- package/src/ngb/accounting/periodClosingApi.ts +60 -0
- package/src/ngb/accounting/periodClosingTypes.ts +73 -0
- package/src/ngb/accounting/types.ts +57 -0
- package/src/ngb/api/audit.ts +23 -0
- package/src/ngb/api/catalogs.ts +50 -0
- package/src/ngb/api/contracts.ts +285 -0
- package/src/ngb/api/documents.ts +170 -0
- package/src/ngb/api/http.ts +446 -0
- package/src/ngb/api/lookups.ts +14 -0
- package/src/ngb/api/types.ts +6 -0
- package/src/ngb/auth/index.ts +13 -0
- package/src/ngb/auth/keycloak.ts +374 -0
- package/src/ngb/auth/router.ts +35 -0
- package/src/ngb/auth/types.ts +12 -0
- package/src/ngb/auth/useAuthStore.ts +209 -0
- package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
- package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
- package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
- package/src/ngb/command-palette/api.ts +10 -0
- package/src/ngb/command-palette/config.ts +16 -0
- package/src/ngb/command-palette/presentation.ts +85 -0
- package/src/ngb/command-palette/search.ts +130 -0
- package/src/ngb/command-palette/staticItems.ts +243 -0
- package/src/ngb/command-palette/storage.ts +45 -0
- package/src/ngb/command-palette/store.ts +716 -0
- package/src/ngb/command-palette/types.ts +120 -0
- package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
- package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
- package/src/ngb/components/NgbConfirmDialog.vue +63 -0
- package/src/ngb/components/NgbDialog.vue +44 -0
- package/src/ngb/components/NgbDrawer.vue +156 -0
- package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
- package/src/ngb/components/NgbModalShell.vue +99 -0
- package/src/ngb/components/NgbToolbar.vue +14 -0
- package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
- package/src/ngb/components/forms/NgbFormRow.vue +21 -0
- package/src/ngb/components/forms/NgbFormSection.vue +16 -0
- package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
- package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
- package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
- package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
- package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
- package/src/ngb/components/register/registerTypes.ts +51 -0
- package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
- package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
- package/src/ngb/components/register/useRegisterRows.ts +209 -0
- package/src/ngb/components/register/useRegisterViewport.ts +56 -0
- package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
- package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
- package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
- package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
- package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
- package/src/ngb/editor/NgbEntityEditor.vue +250 -0
- package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
- package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
- package/src/ngb/editor/catalogNavigation.ts +13 -0
- package/src/ngb/editor/config.ts +252 -0
- package/src/ngb/editor/documentCopyDraft.ts +245 -0
- package/src/ngb/editor/documentEffects.ts +43 -0
- package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
- package/src/ngb/editor/documentNavigation.ts +115 -0
- package/src/ngb/editor/documentStatus.ts +45 -0
- package/src/ngb/editor/entityEditorErrors.ts +162 -0
- package/src/ngb/editor/entityEditorPersistence.ts +241 -0
- package/src/ngb/editor/extensions.ts +40 -0
- package/src/ngb/editor/types.ts +295 -0
- package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
- package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
- package/src/ngb/editor/useEditorDrawerState.ts +88 -0
- package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
- package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
- package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
- package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
- package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
- package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
- package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
- package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
- package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
- package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
- package/src/ngb/env/runtimeConfig.ts +25 -0
- package/src/ngb/lookup/config.ts +42 -0
- package/src/ngb/lookup/defaultConfig.ts +136 -0
- package/src/ngb/lookup/navigation.ts +76 -0
- package/src/ngb/lookup/prefetch.ts +40 -0
- package/src/ngb/lookup/store.ts +261 -0
- package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
- package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
- package/src/ngb/metadata/NgbEntityForm.vue +84 -0
- package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
- package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
- package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
- package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
- package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
- package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
- package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
- package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
- package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
- package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
- package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
- package/src/ngb/metadata/config.ts +28 -0
- package/src/ngb/metadata/dataTypes.ts +27 -0
- package/src/ngb/metadata/entityForm.ts +114 -0
- package/src/ngb/metadata/entityModel.ts +34 -0
- package/src/ngb/metadata/entityValueFormatting.ts +83 -0
- package/src/ngb/metadata/fieldRendererState.ts +84 -0
- package/src/ngb/metadata/filtering.ts +153 -0
- package/src/ngb/metadata/lookup.ts +25 -0
- package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
- package/src/ngb/metadata/normalization.ts +100 -0
- package/src/ngb/metadata/referenceHydration.ts +99 -0
- package/src/ngb/metadata/register.ts +135 -0
- package/src/ngb/metadata/routePages.ts +86 -0
- package/src/ngb/metadata/store.ts +35 -0
- package/src/ngb/metadata/types.ts +276 -0
- package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
- package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
- package/src/ngb/metadata/useValidationFocus.ts +49 -0
- package/src/ngb/primitives/NgbBadge.vue +31 -0
- package/src/ngb/primitives/NgbButton.vue +53 -0
- package/src/ngb/primitives/NgbCheckbox.vue +33 -0
- package/src/ngb/primitives/NgbDatePicker.vue +141 -0
- package/src/ngb/primitives/NgbIcon.vue +545 -0
- package/src/ngb/primitives/NgbInput.vue +57 -0
- package/src/ngb/primitives/NgbLookup.vue +264 -0
- package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
- package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
- package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
- package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
- package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
- package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
- package/src/ngb/primitives/NgbSelect.vue +108 -0
- package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
- package/src/ngb/primitives/NgbSwitch.vue +42 -0
- package/src/ngb/primitives/NgbTabs.vue +116 -0
- package/src/ngb/primitives/NgbToastHost.vue +49 -0
- package/src/ngb/primitives/NgbTooltip.vue +27 -0
- package/src/ngb/primitives/iconNames.ts +83 -0
- package/src/ngb/primitives/toast.ts +66 -0
- package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
- package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
- package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
- package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
- package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
- package/src/ngb/reporting/NgbReportPage.vue +1124 -0
- package/src/ngb/reporting/NgbReportSheet.vue +467 -0
- package/src/ngb/reporting/api.ts +35 -0
- package/src/ngb/reporting/composer.ts +904 -0
- package/src/ngb/reporting/config.ts +120 -0
- package/src/ngb/reporting/defaultConfig.ts +15 -0
- package/src/ngb/reporting/lookupFilters.ts +43 -0
- package/src/ngb/reporting/navigation.ts +181 -0
- package/src/ngb/reporting/pageHelpers.ts +73 -0
- package/src/ngb/reporting/pageSession.ts +92 -0
- package/src/ngb/reporting/paging.ts +66 -0
- package/src/ngb/reporting/types.ts +333 -0
- package/src/ngb/router/backNavigation.ts +122 -0
- package/src/ngb/router/queryParams.ts +129 -0
- package/src/ngb/router/queryState.ts +121 -0
- package/src/ngb/router/routeAliases.ts +66 -0
- package/src/ngb/router/routeParams.ts +15 -0
- package/src/ngb/router/shareLink.ts +39 -0
- package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
- package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
- package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
- package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
- package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
- package/src/ngb/security/NgbRolesPage.vue +125 -0
- package/src/ngb/security/NgbUserEditorPage.vue +615 -0
- package/src/ngb/security/NgbUsersPage.vue +125 -0
- package/src/ngb/security/api.ts +91 -0
- package/src/ngb/security/audit.ts +21 -0
- package/src/ngb/security/permissions.ts +65 -0
- package/src/ngb/security/types.ts +157 -0
- package/src/ngb/security/useAccessStore.ts +69 -0
- package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
- package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
- package/src/ngb/site/NgbLogo.vue +44 -0
- package/src/ngb/site/NgbPageHeader.vue +49 -0
- package/src/ngb/site/NgbSiteShell.vue +286 -0
- package/src/ngb/site/NgbSiteSidebar.vue +270 -0
- package/src/ngb/site/NgbTopBar.vue +425 -0
- package/src/ngb/site/NgbTrendChart.vue +224 -0
- package/src/ngb/site/dashboardData.ts +324 -0
- package/src/ngb/site/mainMenuStore.ts +49 -0
- package/src/ngb/site/types.ts +31 -0
- package/src/ngb/site/useDashboardPageState.ts +98 -0
- package/src/ngb/site/useTheme.ts +95 -0
- package/src/ngb/utils/clone.ts +43 -0
- package/src/ngb/utils/dateValues.ts +107 -0
- package/src/ngb/utils/errorMessage.ts +14 -0
- package/src/ngb/utils/guid.ts +21 -0
- package/src/ngb/utils/stableValue.ts +21 -0
- package/src/ngb/utils/storage.ts +135 -0
- package/src/styles/tailwind.css +165 -0
- package/src/vite/publicAssets.ts +104 -0
- package/vite-public-assets.js +68 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { forceRefreshAccessToken, getAccessToken } from '../auth/keycloak'
|
|
2
|
+
import { readAppEnv } from '../env/runtimeConfig'
|
|
3
|
+
import type { JsonValue, QueryParams } from './types'
|
|
4
|
+
|
|
5
|
+
export type ApiProblemDetails = {
|
|
6
|
+
title?: string
|
|
7
|
+
detail?: string
|
|
8
|
+
status?: number
|
|
9
|
+
type?: string
|
|
10
|
+
instance?: string
|
|
11
|
+
traceId?: string
|
|
12
|
+
[key: string]: JsonValue | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ApiValidationErrors = Record<string, string[]>
|
|
16
|
+
|
|
17
|
+
export type ApiValidationIssue = {
|
|
18
|
+
path: string
|
|
19
|
+
message: string
|
|
20
|
+
scope: string
|
|
21
|
+
code?: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ApiErrorEnvelope = {
|
|
25
|
+
code?: string | null
|
|
26
|
+
kind?: string | null
|
|
27
|
+
context?: Record<string, unknown> | null
|
|
28
|
+
errors?: ApiValidationErrors | null
|
|
29
|
+
issues?: ApiValidationIssue[] | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HttpRequestOptions = {
|
|
33
|
+
signal?: AbortSignal
|
|
34
|
+
retryOnUnauthorized?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeValidationErrors(value: unknown): ApiValidationErrors | null {
|
|
42
|
+
if (!isRecord(value)) return null
|
|
43
|
+
|
|
44
|
+
const result: ApiValidationErrors = {}
|
|
45
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
46
|
+
const messages = Array.isArray(raw)
|
|
47
|
+
? raw.map((entry) => (entry == null ? '' : String(entry).trim())).filter((entry) => entry.length > 0)
|
|
48
|
+
: raw == null
|
|
49
|
+
? []
|
|
50
|
+
: [String(raw).trim()].filter((entry) => entry.length > 0)
|
|
51
|
+
|
|
52
|
+
if (messages.length > 0) result[key] = messages
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Object.keys(result).length > 0 ? result : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeValidationPath(raw: unknown): string {
|
|
59
|
+
let path = typeof raw === 'string' ? raw.trim() : ''
|
|
60
|
+
if (!path) return '_form'
|
|
61
|
+
if (path === '_form') return path
|
|
62
|
+
|
|
63
|
+
if (path.startsWith('$')) path = path.slice(1)
|
|
64
|
+
if (path.startsWith('.')) path = path.slice(1)
|
|
65
|
+
|
|
66
|
+
const stripPrefix = (value: string, prefix: string) =>
|
|
67
|
+
value.toLowerCase().startsWith(prefix.toLowerCase()) ? value.slice(prefix.length) : value
|
|
68
|
+
|
|
69
|
+
path = stripPrefix(path, 'request.')
|
|
70
|
+
path = stripPrefix(path, 'payload.')
|
|
71
|
+
path = stripPrefix(path, 'fields.')
|
|
72
|
+
|
|
73
|
+
path = path.replace(/^([^\.]+)\.rows\[(\d+)\]\.(.+)$/i, '$1[$2].$3')
|
|
74
|
+
path = path.replace(/^([^\.]+)\.rows\[(\d+)\]$/i, '$1[$2]')
|
|
75
|
+
path = stripPrefix(path, 'parts.')
|
|
76
|
+
path = path.replace(/\.rows\[(\d+)\]/gi, '[$1]')
|
|
77
|
+
path = path.replace(/\.rows\[\]/gi, '[]')
|
|
78
|
+
path = path.replace(/^\.+|\.+$/g, '')
|
|
79
|
+
|
|
80
|
+
if (!path || path.toLowerCase() === 'payload') return '_form'
|
|
81
|
+
return path
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferIssueScope(path: string): string {
|
|
85
|
+
if (!path || path === '_form') return 'form'
|
|
86
|
+
if (/\[(?:\d+)?\]$/.test(path)) return /\[\]$/.test(path) ? 'collection' : 'row'
|
|
87
|
+
return 'field'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeValidationIssue(value: unknown): ApiValidationIssue | null {
|
|
91
|
+
if (!isRecord(value)) return null
|
|
92
|
+
|
|
93
|
+
const rawMessage = typeof value.message === 'string'
|
|
94
|
+
? value.message
|
|
95
|
+
: typeof value.Message === 'string'
|
|
96
|
+
? value.Message
|
|
97
|
+
: ''
|
|
98
|
+
const message = rawMessage.trim()
|
|
99
|
+
if (!message) return null
|
|
100
|
+
|
|
101
|
+
const path = normalizeValidationPath(
|
|
102
|
+
typeof value.path === 'string'
|
|
103
|
+
? value.path
|
|
104
|
+
: typeof value.Path === 'string'
|
|
105
|
+
? value.Path
|
|
106
|
+
: ''
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const rawScope = typeof value.scope === 'string'
|
|
110
|
+
? value.scope.trim()
|
|
111
|
+
: typeof value.Scope === 'string'
|
|
112
|
+
? value.Scope.trim()
|
|
113
|
+
: ''
|
|
114
|
+
|
|
115
|
+
const code = typeof value.code === 'string'
|
|
116
|
+
? value.code
|
|
117
|
+
: typeof value.Code === 'string'
|
|
118
|
+
? value.Code
|
|
119
|
+
: null
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
path,
|
|
123
|
+
message,
|
|
124
|
+
scope: rawScope || inferIssueScope(path),
|
|
125
|
+
code,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeValidationIssues(value: unknown): ApiValidationIssue[] | null {
|
|
130
|
+
if (!Array.isArray(value)) return null
|
|
131
|
+
const issues = value
|
|
132
|
+
.map((entry) => normalizeValidationIssue(entry))
|
|
133
|
+
.filter((entry): entry is ApiValidationIssue => !!entry)
|
|
134
|
+
|
|
135
|
+
return issues.length > 0 ? issues : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validationIssuesFromErrors(errors: ApiValidationErrors | null | undefined): ApiValidationIssue[] | null {
|
|
139
|
+
if (!errors) return null
|
|
140
|
+
|
|
141
|
+
const issues: ApiValidationIssue[] = []
|
|
142
|
+
for (const [rawPath, messages] of Object.entries(errors)) {
|
|
143
|
+
const path = normalizeValidationPath(rawPath)
|
|
144
|
+
const scope = inferIssueScope(path)
|
|
145
|
+
for (const message of messages ?? []) {
|
|
146
|
+
const text = String(message ?? '').trim()
|
|
147
|
+
if (!text) continue
|
|
148
|
+
issues.push({ path, message: text, scope, code: null })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return issues.length > 0 ? issues : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractErrorEnvelope(body: unknown): ApiErrorEnvelope | null {
|
|
156
|
+
if (!isRecord(body)) return null
|
|
157
|
+
|
|
158
|
+
const nested = isRecord(body.error) ? body.error : null
|
|
159
|
+
const flatErrors = normalizeValidationErrors(body.errors)
|
|
160
|
+
const nestedErrors = normalizeValidationErrors(nested?.errors ?? nested?.Errors)
|
|
161
|
+
const flatIssues = normalizeValidationIssues(body.issues)
|
|
162
|
+
const nestedIssues = normalizeValidationIssues(nested?.issues ?? nested?.Issues)
|
|
163
|
+
const context = isRecord(body.context)
|
|
164
|
+
? body.context
|
|
165
|
+
: isRecord(nested?.context)
|
|
166
|
+
? nested?.context
|
|
167
|
+
: isRecord(nested?.Context)
|
|
168
|
+
? nested?.Context
|
|
169
|
+
: null
|
|
170
|
+
|
|
171
|
+
const code = typeof body.errorCode === 'string'
|
|
172
|
+
? body.errorCode
|
|
173
|
+
: typeof nested?.code === 'string'
|
|
174
|
+
? nested.code
|
|
175
|
+
: typeof nested?.Code === 'string'
|
|
176
|
+
? nested.Code
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const kind = typeof body.kind === 'string'
|
|
180
|
+
? body.kind
|
|
181
|
+
: typeof nested?.kind === 'string'
|
|
182
|
+
? nested.kind
|
|
183
|
+
: typeof nested?.Kind === 'string'
|
|
184
|
+
? nested.Kind
|
|
185
|
+
: null
|
|
186
|
+
|
|
187
|
+
const errors = nestedErrors ?? flatErrors
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
code,
|
|
191
|
+
kind,
|
|
192
|
+
context,
|
|
193
|
+
errors,
|
|
194
|
+
issues: nestedIssues ?? flatIssues ?? validationIssuesFromErrors(errors),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isGenericProblemDetail(detail: string): boolean {
|
|
199
|
+
const normalized = detail.trim().toLowerCase()
|
|
200
|
+
return normalized === 'one or more validation errors has occurred.'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function firstValidationMessage(errors: ApiValidationErrors | null | undefined): string | null {
|
|
204
|
+
if (!errors) return null
|
|
205
|
+
for (const messages of Object.values(errors)) {
|
|
206
|
+
const first = messages.find((entry) => entry.trim().length > 0)
|
|
207
|
+
if (first) return first
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function firstValidationIssueMessage(issues: ApiValidationIssue[] | null | undefined): string | null {
|
|
213
|
+
if (!issues) return null
|
|
214
|
+
const first = issues.find((issue) => issue.message.trim().length > 0)
|
|
215
|
+
return first?.message?.trim() || null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export class ApiError extends Error {
|
|
219
|
+
readonly status: number
|
|
220
|
+
readonly url: string
|
|
221
|
+
readonly body?: unknown
|
|
222
|
+
readonly problem?: ApiProblemDetails | null
|
|
223
|
+
readonly errorCode?: string | null
|
|
224
|
+
readonly kind?: string | null
|
|
225
|
+
readonly context?: Record<string, unknown> | null
|
|
226
|
+
readonly errors?: ApiValidationErrors | null
|
|
227
|
+
readonly issues?: ApiValidationIssue[] | null
|
|
228
|
+
|
|
229
|
+
constructor(args: { message: string; status: number; url: string; body?: unknown }) {
|
|
230
|
+
super(args.message)
|
|
231
|
+
this.name = 'ApiError'
|
|
232
|
+
this.status = args.status
|
|
233
|
+
this.url = args.url
|
|
234
|
+
this.body = args.body
|
|
235
|
+
|
|
236
|
+
const envelope = extractErrorEnvelope(args.body)
|
|
237
|
+
this.problem = isRecord(args.body) ? (args.body as ApiProblemDetails) : null
|
|
238
|
+
this.errorCode = envelope?.code ?? null
|
|
239
|
+
this.kind = envelope?.kind ?? null
|
|
240
|
+
this.context = envelope?.context ?? null
|
|
241
|
+
this.errors = envelope?.errors ?? null
|
|
242
|
+
this.issues = envelope?.issues ?? null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function apiBaseUrl(): string {
|
|
247
|
+
const base = readAppEnv('VITE_API_BASE_URL')
|
|
248
|
+
return base.length > 0 ? base : window.location.origin
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isJsonContentType(contentType: string | null | undefined): boolean {
|
|
252
|
+
const normalized = (contentType ?? '').toLowerCase()
|
|
253
|
+
return normalized.includes('json')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function resolveUrl(url: string): string {
|
|
257
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
258
|
+
const path = url.startsWith('/') ? url : `/${url}`
|
|
259
|
+
return new URL(path, apiBaseUrl()).toString()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function tryReadJson(response: Response): Promise<unknown | null> {
|
|
263
|
+
if (!isJsonContentType(response.headers.get('content-type'))) return null
|
|
264
|
+
try {
|
|
265
|
+
return await response.json() as unknown
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function tryReadText(response: Response): Promise<string | null> {
|
|
272
|
+
try {
|
|
273
|
+
return await response.text()
|
|
274
|
+
} catch {
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toApiErrorMessage(status: number, body: unknown): string {
|
|
280
|
+
if (!body) return `HTTP ${status}`
|
|
281
|
+
|
|
282
|
+
const envelope = extractErrorEnvelope(body)
|
|
283
|
+
const detail = isRecord(body) && typeof body.detail === 'string' ? body.detail.trim() : ''
|
|
284
|
+
const title = isRecord(body) && typeof body.title === 'string' ? body.title.trim() : ''
|
|
285
|
+
const firstIssue = firstValidationIssueMessage(envelope?.issues)
|
|
286
|
+
const firstError = firstValidationMessage(envelope?.errors)
|
|
287
|
+
|
|
288
|
+
if (detail && !isGenericProblemDetail(detail)) return detail
|
|
289
|
+
if (firstIssue) return firstIssue
|
|
290
|
+
if (firstError) return firstError
|
|
291
|
+
|
|
292
|
+
if (isRecord(body) && typeof body.message === 'string' && body.message.trim().length > 0) return body.message.trim()
|
|
293
|
+
if (title) return title
|
|
294
|
+
if (typeof envelope?.code === 'string' && envelope.code.length > 0) return `${envelope.code} (HTTP ${status})`
|
|
295
|
+
|
|
296
|
+
return `HTTP ${status}`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function appendQuery(url: string, query: QueryParams | null | undefined): string {
|
|
300
|
+
if (!query) return url
|
|
301
|
+
const params = new URLSearchParams()
|
|
302
|
+
for (const [key, value] of Object.entries(query)) {
|
|
303
|
+
if (value === undefined || value === null || value === '') continue
|
|
304
|
+
params.set(key, String(value))
|
|
305
|
+
}
|
|
306
|
+
const serialized = params.toString()
|
|
307
|
+
if (!serialized) return url
|
|
308
|
+
return url.includes('?') ? `${url}&${serialized}` : `${url}?${serialized}`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function buildJsonHeaders(body: unknown, accept: string): Promise<Record<string, string>> {
|
|
312
|
+
const headers: Record<string, string> = { Accept: accept }
|
|
313
|
+
if (body != null) headers['Content-Type'] = 'application/json'
|
|
314
|
+
|
|
315
|
+
const token = await getAccessToken()
|
|
316
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
317
|
+
|
|
318
|
+
return headers
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function buildResponse(response: Response, resolvedUrl: string): Promise<never> {
|
|
322
|
+
const json = await tryReadJson(response)
|
|
323
|
+
const message = toApiErrorMessage(response.status, json)
|
|
324
|
+
throw new ApiError({ message, status: response.status, url: resolvedUrl, body: json ?? undefined })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function httpRequest<T>(
|
|
328
|
+
method: string,
|
|
329
|
+
url: string,
|
|
330
|
+
body?: unknown,
|
|
331
|
+
options?: HttpRequestOptions,
|
|
332
|
+
): Promise<T> {
|
|
333
|
+
const resolvedUrl = resolveUrl(url)
|
|
334
|
+
const response = await fetch(resolvedUrl, {
|
|
335
|
+
method,
|
|
336
|
+
credentials: 'omit',
|
|
337
|
+
headers: await buildJsonHeaders(body, 'application/json'),
|
|
338
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
339
|
+
signal: options?.signal,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
if (response.status === 401 && options?.retryOnUnauthorized !== false) {
|
|
343
|
+
const refreshedToken = await forceRefreshAccessToken().catch(() => null)
|
|
344
|
+
if (refreshedToken) {
|
|
345
|
+
return await httpRequest<T>(method, url, body, { ...options, retryOnUnauthorized: false })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!response.ok) await buildResponse(response, resolvedUrl)
|
|
350
|
+
if (response.status === 204) return undefined as unknown as T
|
|
351
|
+
|
|
352
|
+
const json = await tryReadJson(response)
|
|
353
|
+
if (json == null) {
|
|
354
|
+
const text = await tryReadText(response)
|
|
355
|
+
throw new ApiError({
|
|
356
|
+
message: `Expected JSON but got '${response.headers.get('content-type') ?? 'unknown'}' for ${resolvedUrl}`,
|
|
357
|
+
status: response.status,
|
|
358
|
+
url: resolvedUrl,
|
|
359
|
+
body: text ?? undefined,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return json as T
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function httpGet<T>(
|
|
367
|
+
url: string,
|
|
368
|
+
query?: QueryParams | null,
|
|
369
|
+
options?: HttpRequestOptions,
|
|
370
|
+
): Promise<T> {
|
|
371
|
+
return httpRequest<T>('GET', appendQuery(url, query ?? undefined), undefined, options)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function httpPost<TResponse, TBody = unknown>(
|
|
375
|
+
url: string,
|
|
376
|
+
body?: TBody,
|
|
377
|
+
options?: HttpRequestOptions,
|
|
378
|
+
): Promise<TResponse> {
|
|
379
|
+
return httpRequest<TResponse>('POST', url, body, options)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function httpPut<TResponse, TBody = unknown>(
|
|
383
|
+
url: string,
|
|
384
|
+
body?: TBody,
|
|
385
|
+
options?: HttpRequestOptions,
|
|
386
|
+
): Promise<TResponse> {
|
|
387
|
+
return httpRequest<TResponse>('PUT', url, body, options)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function httpDelete<TResponse, TBody = unknown>(
|
|
391
|
+
url: string,
|
|
392
|
+
body?: TBody,
|
|
393
|
+
options?: HttpRequestOptions,
|
|
394
|
+
): Promise<TResponse> {
|
|
395
|
+
return httpRequest<TResponse>('DELETE', url, body, options)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export type HttpFileResponse = {
|
|
399
|
+
blob: Blob
|
|
400
|
+
fileName: string | null
|
|
401
|
+
contentType: string | null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parseFileNameFromContentDisposition(value: string | null): string | null {
|
|
405
|
+
const raw = String(value ?? '').trim()
|
|
406
|
+
if (!raw) return null
|
|
407
|
+
|
|
408
|
+
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(raw)
|
|
409
|
+
if (utf8Match?.[1]) {
|
|
410
|
+
try {
|
|
411
|
+
return decodeURIComponent(utf8Match[1].trim())
|
|
412
|
+
} catch {
|
|
413
|
+
return utf8Match[1].trim()
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const basicMatch = /filename=\"?([^\";]+)\"?/i.exec(raw)
|
|
418
|
+
return basicMatch?.[1]?.trim() || null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function httpPostFile<TBody = unknown>(url: string, body?: TBody): Promise<HttpFileResponse> {
|
|
422
|
+
return await httpPostFileInternal(url, body, true)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function httpPostFileInternal(url: string, body: unknown, retryOnUnauthorized: boolean): Promise<HttpFileResponse> {
|
|
426
|
+
const resolvedUrl = resolveUrl(url)
|
|
427
|
+
const response = await fetch(resolvedUrl, {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
credentials: 'omit',
|
|
430
|
+
headers: await buildJsonHeaders(body, '*/*'),
|
|
431
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
if (response.status === 401 && retryOnUnauthorized) {
|
|
435
|
+
const refreshedToken = await forceRefreshAccessToken().catch(() => null)
|
|
436
|
+
if (refreshedToken) return await httpPostFileInternal(url, body, false)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!response.ok) await buildResponse(response, resolvedUrl)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
blob: await response.blob(),
|
|
443
|
+
fileName: parseFileNameFromContentDisposition(response.headers.get('content-disposition')),
|
|
444
|
+
contentType: response.headers.get('content-type'),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { httpGet, httpPost } from './http'
|
|
2
|
+
import type { ByIdsRequestDto, LookupItemDto } from './contracts'
|
|
3
|
+
import type { QueryParams } from './types'
|
|
4
|
+
|
|
5
|
+
export async function lookupCatalog(catalogType: string, q: string | null, limit = 20): Promise<LookupItemDto[]> {
|
|
6
|
+
const query: QueryParams = { limit }
|
|
7
|
+
if (q && q.trim().length > 0) query.q = q
|
|
8
|
+
return await httpGet<LookupItemDto[]>(`/api/catalogs/${encodeURIComponent(catalogType)}/lookup`, query)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getCatalogLookupByIds(catalogType: string, ids: string[]): Promise<LookupItemDto[]> {
|
|
12
|
+
const body: ByIdsRequestDto = { ids }
|
|
13
|
+
return await httpPost<LookupItemDto[]>(`/api/catalogs/${encodeURIComponent(catalogType)}/by-ids`, body)
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type QueryParamValue = string | number | boolean
|
|
2
|
+
export type QueryParams = Record<string, QueryParamValue | null | undefined>
|
|
3
|
+
export type JsonPrimitive = string | number | boolean | null
|
|
4
|
+
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]
|
|
5
|
+
export type JsonObject = { [key: string]: JsonValue }
|
|
6
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
forceRefreshAccessToken,
|
|
3
|
+
getAccessToken,
|
|
4
|
+
getAuthSnapshot,
|
|
5
|
+
initializeAuth,
|
|
6
|
+
loginWithKeycloak,
|
|
7
|
+
logoutFromKeycloak,
|
|
8
|
+
subscribeAuth,
|
|
9
|
+
} from './keycloak'
|
|
10
|
+
export type { AuthSnapshot } from './types'
|
|
11
|
+
export { createAuthGuard } from './router'
|
|
12
|
+
export type { AuthGuardStore } from './router'
|
|
13
|
+
export { useAuthStore } from './useAuthStore'
|