@antelopejs/dms 0.12.0 → 0.13.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/CHANGELOG.md +66 -0
- package/README.md +1 -1
- package/data/dbip-country-lite.mmdb +0 -0
- package/data/module-store/catalog.json +0 -6
- package/dist/config/internal/state.d.ts +5 -0
- package/dist/config/internal/state.js.map +1 -1
- package/dist/frontend-module.js +1 -0
- package/dist/frontend-module.js.map +1 -1
- package/dist/implementations/dms/page/quick-action-access.js +4 -1
- package/dist/implementations/dms/page/quick-action-access.js.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/store/operations.js +3 -1
- package/dist/store/operations.js.map +1 -1
- package/dist/store/runtime.d.ts +1 -0
- package/dist/store/runtime.js +9 -0
- package/dist/store/runtime.js.map +1 -1
- package/dist/utils/ensure-default-tenant.js +7 -2
- package/dist/utils/ensure-default-tenant.js.map +1 -1
- package/frontend-vue/dms.frontend.ts +2 -0
- package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
- package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -11
- package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/login.vue +3 -1
- package/frontend-vue/layers/dms-auth/server/oauth.ts +5 -3
- package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
- package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
- package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +2 -2
- package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
- package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -10
- package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
- package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -26
- package/frontend-vue/layers/dms-core/app/utils/composedText.ts +11 -4
- package/frontend-vue/layers/dms-core/app/utils/row-action-rule-evaluator.ts +8 -36
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
- package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +6 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidePanel.vue +3 -2
- package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +14 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +6 -6
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
- package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +1 -1
- package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
- package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
- package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
- package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +2 -4
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +2 -4
- package/frontend-vue/layers/dms-layout/server/render-token.ts +5 -3
- package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
- package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +51 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -8
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/defaultPrimitiveTypes.ts +6 -8
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +2 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +9 -9
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +8 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
- package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
- package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +4 -1
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -16
- package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +2 -4
- package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
- package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -9
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +11 -1
- package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -21
- package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
- package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
- package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
- package/frontend-vue/package.json +3 -5
- package/frontend-vue/scripts/check-api-error-presentation.mjs +242 -0
- package/frontend-vue/scripts/check-invariants.mjs +23 -0
- package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
- package/frontend-vue/scripts/test-api-error-presentation.mjs +93 -0
- package/package.json +23 -7
- package/skills/dms-module/REFERENCE.md +3 -1
- package/skills/dms-module/SKILL.md +7 -2
- package/skills/dms-project/SKILL.md +9 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { toRaw } from "vue";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type {
|
|
4
|
+
ApiErrorParams,
|
|
5
|
+
ApiErrorSeverity,
|
|
6
|
+
} from "../../composables/useApiErrorPresenter";
|
|
7
|
+
|
|
8
|
+
const MAX_DEPTH = 8;
|
|
9
|
+
const HTTP_FAILURE_MIN = 400;
|
|
10
|
+
const HTTP_CLIENT_MAX = 499;
|
|
11
|
+
const HTTP_STATUS_MAX = 599;
|
|
12
|
+
const statusSchema = z
|
|
13
|
+
.number()
|
|
14
|
+
.int()
|
|
15
|
+
.min(HTTP_FAILURE_MIN)
|
|
16
|
+
.max(HTTP_STATUS_MAX);
|
|
17
|
+
const severitySchema = z.enum(["info", "warning", "error"]);
|
|
18
|
+
const paramsSchema = z.record(z.union([z.string(), z.number(), z.boolean()]));
|
|
19
|
+
const recordSchema = z.object({
|
|
20
|
+
cause: z.unknown().optional(),
|
|
21
|
+
response: z.unknown().optional(),
|
|
22
|
+
data: z.unknown().optional(),
|
|
23
|
+
_data: z.unknown().optional(),
|
|
24
|
+
message: z.unknown().optional(),
|
|
25
|
+
name: z.unknown().optional(),
|
|
26
|
+
severity: z.unknown().optional(),
|
|
27
|
+
params: z.unknown().optional(),
|
|
28
|
+
status: z.unknown().optional(),
|
|
29
|
+
statusCode: z.unknown().optional(),
|
|
30
|
+
body: z.unknown().optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
interface DecodedBody {
|
|
34
|
+
key?: string;
|
|
35
|
+
params?: ApiErrorParams;
|
|
36
|
+
severity?: ApiErrorSeverity;
|
|
37
|
+
status?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @internal */
|
|
41
|
+
export interface DecodedApiFailure {
|
|
42
|
+
cancelled: boolean;
|
|
43
|
+
identity: object | null;
|
|
44
|
+
key?: string;
|
|
45
|
+
params?: ApiErrorParams;
|
|
46
|
+
severity: ApiErrorSeverity;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function record(value: unknown) {
|
|
50
|
+
return recordSchema.safeParse(value).data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decodeBody(input: unknown): DecodedBody {
|
|
54
|
+
let value = input;
|
|
55
|
+
let status: number | undefined;
|
|
56
|
+
const seen = new Set<unknown>();
|
|
57
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
58
|
+
if (seen.has(value)) return { status };
|
|
59
|
+
seen.add(value);
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
const text = value;
|
|
62
|
+
try {
|
|
63
|
+
const parsed: unknown = JSON.parse(text);
|
|
64
|
+
value = parsed;
|
|
65
|
+
continue;
|
|
66
|
+
} catch {
|
|
67
|
+
return { key: text, status };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const body = record(value);
|
|
71
|
+
if (!body) return { status };
|
|
72
|
+
const envelopeStatus = statusSchema.safeParse(body.status).data;
|
|
73
|
+
if (envelopeStatus !== undefined && body.body !== undefined) {
|
|
74
|
+
status ??= envelopeStatus;
|
|
75
|
+
value = body.body;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
key: z.string().safeParse(body.message).data,
|
|
80
|
+
severity: severitySchema.safeParse(body.severity).data,
|
|
81
|
+
params: paramsSchema.safeParse(body.params).data,
|
|
82
|
+
status,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { status };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @internal */
|
|
89
|
+
export function decodeApiFailure(error: unknown): DecodedApiFailure {
|
|
90
|
+
let current = error;
|
|
91
|
+
let identity: object | null = null;
|
|
92
|
+
let request: DecodedBody | undefined;
|
|
93
|
+
let local: unknown = error;
|
|
94
|
+
let status: number | undefined;
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
let hasFailedResponse = false;
|
|
97
|
+
let params: ApiErrorParams | undefined;
|
|
98
|
+
const seen = new Set<unknown>();
|
|
99
|
+
for (let depth = 0; depth < MAX_DEPTH && !seen.has(current); depth++) {
|
|
100
|
+
seen.add(current);
|
|
101
|
+
const entry = record(current);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
local = current;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (current !== null && typeof current === "object")
|
|
107
|
+
identity = toRaw(current);
|
|
108
|
+
local = entry.message;
|
|
109
|
+
params ??= paramsSchema.safeParse(entry.params).data;
|
|
110
|
+
cancelled ||= entry.name === "AbortError";
|
|
111
|
+
const response = record(entry.response);
|
|
112
|
+
const responseStatus = statusSchema.safeParse(response?.status).data;
|
|
113
|
+
const entryStatus =
|
|
114
|
+
responseStatus ??
|
|
115
|
+
statusSchema.safeParse(entry.statusCode ?? entry.status).data;
|
|
116
|
+
if (
|
|
117
|
+
entry.response &&
|
|
118
|
+
responseStatus !== undefined &&
|
|
119
|
+
typeof entry.response === "object"
|
|
120
|
+
) {
|
|
121
|
+
identity = toRaw(entry.response);
|
|
122
|
+
request = decodeBody(entry.data ?? response?._data);
|
|
123
|
+
status = responseStatus;
|
|
124
|
+
hasFailedResponse = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if (entry.data !== undefined || entryStatus !== undefined) {
|
|
128
|
+
request = decodeBody(entry.data);
|
|
129
|
+
status = entryStatus;
|
|
130
|
+
}
|
|
131
|
+
if (entry.cause === undefined) break;
|
|
132
|
+
current = entry.cause;
|
|
133
|
+
}
|
|
134
|
+
const body = request ?? decodeBody(local);
|
|
135
|
+
status ??= body.status;
|
|
136
|
+
return {
|
|
137
|
+
cancelled: cancelled && !hasFailedResponse,
|
|
138
|
+
identity,
|
|
139
|
+
key: body.key,
|
|
140
|
+
params: { ...params, ...body.params },
|
|
141
|
+
severity:
|
|
142
|
+
body.severity ??
|
|
143
|
+
(status !== undefined && status <= HTTP_CLIENT_MAX ? "warning" : "error"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -155,6 +155,7 @@ function createPreviewSharing() {
|
|
|
155
155
|
*/
|
|
156
156
|
export function usePermissionPreview() {
|
|
157
157
|
const state = usePreviewState();
|
|
158
|
+
const { presentError } = useApiErrorPresenter();
|
|
158
159
|
const isActive = computed(() => state.session.value !== null);
|
|
159
160
|
const hiddenEntries = computed(
|
|
160
161
|
() => new Set(state.result.value?.hiddenEntries ?? []),
|
|
@@ -249,9 +250,12 @@ export function usePermissionPreview() {
|
|
|
249
250
|
if (current !== refreshGeneration) return;
|
|
250
251
|
state.result.value = result;
|
|
251
252
|
state.failed.value = false;
|
|
252
|
-
} catch {
|
|
253
|
+
} catch (error) {
|
|
253
254
|
if (current !== refreshGeneration) return;
|
|
254
255
|
state.failed.value = true;
|
|
256
|
+
presentError(error, {
|
|
257
|
+
fallbackKey: "page.settings.roles.preview.failed",
|
|
258
|
+
});
|
|
255
259
|
} finally {
|
|
256
260
|
if (current === refreshGeneration) state.loading.value = false;
|
|
257
261
|
}
|
|
@@ -126,12 +126,12 @@ export const useAuthFetch = () => {
|
|
|
126
126
|
...defaults,
|
|
127
127
|
...options,
|
|
128
128
|
});
|
|
129
|
-
//
|
|
129
|
+
// SAFETY: retryingFetch resolves with the response baseFetch gives for the same request, of the same response type, so the wrapper answers as AuthFetch's call signature does; raw, native and create are attached below before it is returned.
|
|
130
130
|
const authFetch = ((
|
|
131
131
|
request: AuthFetchArgs[0],
|
|
132
132
|
options?: AuthFetchArgs[1],
|
|
133
133
|
) => retryingFetch(request, optionsOf(options))) as AuthFetch;
|
|
134
|
-
//
|
|
134
|
+
// SAFETY: retryingRaw resolves with the response baseFetch.raw gives for the same request, of the same response type, so the wrapper answers as AuthFetch['raw'] does.
|
|
135
135
|
authFetch.raw = ((request: AuthFetchArgs[0], options?: AuthFetchArgs[1]) =>
|
|
136
136
|
retryingRaw(request, optionsOf(options))) as AuthFetch["raw"];
|
|
137
137
|
authFetch.native = baseFetch.native;
|
|
@@ -209,18 +209,19 @@ const useSiteLayoutRequests = () => {
|
|
|
209
209
|
const { pageLayoutPromises, loadingPromise } =
|
|
210
210
|
useSiteLayoutRequestsInFlight();
|
|
211
211
|
|
|
212
|
-
const
|
|
213
|
-
const UNKNOWN_SITE_LAYOUT_ERROR = "Unknown error loading siteLayout";
|
|
212
|
+
const { resolveError } = useApiErrorPresenter();
|
|
214
213
|
const HTTP_INTERNAL_ERROR = 500;
|
|
215
214
|
|
|
216
215
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Fetch rejection values have no shared owner schema; an Error-only input would exclude malformed thrown values that intentionally use the fallback message.
|
|
217
216
|
const handleSiteLayoutError = (error: unknown) => {
|
|
218
|
-
const errorMessage =
|
|
219
|
-
|
|
217
|
+
const errorMessage = resolveError(error, {
|
|
218
|
+
fallbackKey: "error.500.description",
|
|
219
|
+
})?.message;
|
|
220
|
+
if (!errorMessage) return;
|
|
220
221
|
loadingError.value = errorMessage;
|
|
221
222
|
showDmsError({
|
|
222
223
|
statusCode: HTTP_INTERNAL_ERROR,
|
|
223
|
-
statusMessage:
|
|
224
|
+
statusMessage: errorMessage,
|
|
224
225
|
message: errorMessage,
|
|
225
226
|
});
|
|
226
227
|
};
|
|
@@ -69,19 +69,12 @@ export function resolveApiErrorMessage(
|
|
|
69
69
|
return isApiError(error) ? resolveDescription(error) : FALLBACK_ERROR_KEY;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** @deprecated Capture useApiErrorPresenter during setup instead. */
|
|
72
73
|
export function useApiError(
|
|
73
74
|
error: Parameters<typeof resolveApiErrorTitle>[0],
|
|
74
75
|
options?: Options,
|
|
75
76
|
) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const title = options?.title || resolveApiErrorTitle(error);
|
|
80
|
-
const description = options?.description || resolveApiErrorMessage(error);
|
|
81
|
-
|
|
82
|
-
toast.add({
|
|
83
|
-
title: processApiMessage(title),
|
|
84
|
-
description: processApiMessage(description),
|
|
85
|
-
color: "error",
|
|
77
|
+
useApiErrorPresenter().presentError(error, {
|
|
78
|
+
fallbackKey: options?.description || options?.title || FALLBACK_ERROR_KEY,
|
|
86
79
|
});
|
|
87
80
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { decodeApiFailure } from "../build/composables/api-error";
|
|
2
|
+
|
|
3
|
+
/** The supported tones for request failures. */
|
|
4
|
+
export type ApiErrorSeverity = "info" | "warning" | "error";
|
|
5
|
+
|
|
6
|
+
/** Localized text and tone for an inline error. */
|
|
7
|
+
export interface ApiErrorNotice {
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly severity: ApiErrorSeverity;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Placeholder values accepted by error translations. */
|
|
13
|
+
export type ApiErrorParams = Readonly<
|
|
14
|
+
Record<string, string | number | boolean>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
/** Keys are resolved against loaded catalogs and never displayed verbatim. */
|
|
18
|
+
export interface ResolveApiErrorOptions {
|
|
19
|
+
readonly fallbackKey: string;
|
|
20
|
+
readonly params?: ApiErrorParams;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A contextual action whose label is also a loaded translation key. */
|
|
24
|
+
export interface ApiErrorAction {
|
|
25
|
+
readonly labelKey: string;
|
|
26
|
+
readonly onSelect: () => void | Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An existing progress notification, not the identity of a failure. */
|
|
30
|
+
export type ApiErrorToastId = string | number;
|
|
31
|
+
|
|
32
|
+
/** Toast presentation never overrides the server's translated reason or tone. */
|
|
33
|
+
export interface PresentApiErrorOptions extends ResolveApiErrorOptions {
|
|
34
|
+
readonly actions?: readonly ApiErrorAction[];
|
|
35
|
+
readonly replaceToastId?: ApiErrorToastId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Captured during setup and callable after awaits. */
|
|
39
|
+
export interface ApiErrorPresenter {
|
|
40
|
+
resolveError(
|
|
41
|
+
error: unknown,
|
|
42
|
+
options: ResolveApiErrorOptions,
|
|
43
|
+
): ApiErrorNotice | null;
|
|
44
|
+
presentError(error: unknown, options: PresentApiErrorOptions): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const GENERIC_KEY = "error.500.description";
|
|
48
|
+
const PRESENTED_STATE_KEY = "dms.apiErrors.presentedResponses";
|
|
49
|
+
const FAILURE_DURATION_MS = 5000;
|
|
50
|
+
const ICONS: Record<ApiErrorSeverity, string> = {
|
|
51
|
+
info: "i-ph-info",
|
|
52
|
+
warning: "i-ph-warning",
|
|
53
|
+
error: "i-ph-warning-circle",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function isCompiledMessage(value: unknown): boolean {
|
|
57
|
+
return (
|
|
58
|
+
value !== null &&
|
|
59
|
+
typeof value === "object" &&
|
|
60
|
+
(("type" in value && value.type === 0) || ("t" in value && value.t === 0))
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves errors without side effects and presents at most one toast per failed
|
|
66
|
+
* Response in this app. Aborts without a failed response are silent.
|
|
67
|
+
*/
|
|
68
|
+
export function useApiErrorPresenter(): ApiErrorPresenter {
|
|
69
|
+
const toast = useToast();
|
|
70
|
+
const { i18n } = getPluginI18n();
|
|
71
|
+
const presented = useDmsState(
|
|
72
|
+
PRESENTED_STATE_KEY,
|
|
73
|
+
() => new WeakMap<object, ApiErrorToastId | null>(),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function lookup(
|
|
77
|
+
key: string | undefined,
|
|
78
|
+
params: ApiErrorParams = {},
|
|
79
|
+
): string | null {
|
|
80
|
+
if (!key) return null;
|
|
81
|
+
try {
|
|
82
|
+
const message = i18n.tm(key.startsWith("$") ? key.slice(1) : key);
|
|
83
|
+
if (
|
|
84
|
+
typeof message !== "string" &&
|
|
85
|
+
typeof message !== "function" &&
|
|
86
|
+
!isCompiledMessage(message)
|
|
87
|
+
)
|
|
88
|
+
return null;
|
|
89
|
+
return i18n.rt(message, params);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function noticeOf(
|
|
96
|
+
failure: ReturnType<typeof decodeApiFailure>,
|
|
97
|
+
options: ResolveApiErrorOptions,
|
|
98
|
+
): ApiErrorNotice | null {
|
|
99
|
+
if (failure.cancelled) return null;
|
|
100
|
+
const params = { ...failure.params, ...options.params };
|
|
101
|
+
const message =
|
|
102
|
+
lookup(failure.key, params) ??
|
|
103
|
+
lookup(options.fallbackKey, params) ??
|
|
104
|
+
lookup(GENERIC_KEY);
|
|
105
|
+
if (message === null)
|
|
106
|
+
throw new Error(
|
|
107
|
+
"The core error.500.description translation must be loaded",
|
|
108
|
+
);
|
|
109
|
+
return { message, severity: failure.severity };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveError(
|
|
113
|
+
error: unknown,
|
|
114
|
+
options: ResolveApiErrorOptions,
|
|
115
|
+
): ApiErrorNotice | null {
|
|
116
|
+
return noticeOf(decodeApiFailure(error), options);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function presentError(error: unknown, options: PresentApiErrorOptions): void {
|
|
120
|
+
const failure = decodeApiFailure(error);
|
|
121
|
+
const notice = noticeOf(failure, options);
|
|
122
|
+
if (import.meta.env.SSR) return;
|
|
123
|
+
const replacement = options.replaceToastId;
|
|
124
|
+
const identity = failure.identity;
|
|
125
|
+
if (!notice || (identity && presented.value.has(identity))) {
|
|
126
|
+
if (
|
|
127
|
+
replacement !== undefined &&
|
|
128
|
+
(!identity || presented.value.get(identity) !== replacement)
|
|
129
|
+
)
|
|
130
|
+
toast.remove(replacement);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const payload = {
|
|
134
|
+
title: notice.message,
|
|
135
|
+
description: undefined,
|
|
136
|
+
color: notice.severity,
|
|
137
|
+
icon: ICONS[notice.severity],
|
|
138
|
+
duration: FAILURE_DURATION_MS,
|
|
139
|
+
progress: true,
|
|
140
|
+
actions: options.actions?.flatMap((action) => {
|
|
141
|
+
const label = lookup(action.labelKey);
|
|
142
|
+
return label === null ? [] : [{ label, onClick: action.onSelect }];
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
if (identity) presented.value.set(identity, replacement ?? null);
|
|
146
|
+
try {
|
|
147
|
+
if (replacement !== undefined) toast.update(replacement, payload);
|
|
148
|
+
else {
|
|
149
|
+
const added = toast.add(payload);
|
|
150
|
+
if (identity) presented.value.set(identity, added.id);
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (identity) presented.value.delete(identity);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { presentError, resolveError };
|
|
159
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { nextTick, reactive } from "vue";
|
|
2
|
-
import {
|
|
3
|
-
type I18nTranslate,
|
|
4
|
-
resolveApiMessage,
|
|
5
|
-
} from "./translation/useTranslation";
|
|
2
|
+
import { type I18nTranslate } from "./translation/useTranslation";
|
|
6
3
|
import { resolveApiErrorMessage } from "./useApiError";
|
|
7
4
|
import { isBoolean, isNumber, isObject, isString } from "../utils/type-check";
|
|
8
5
|
|
|
@@ -19,11 +16,10 @@ import { isBoolean, isNumber, isObject, isString } from "../utils/type-check";
|
|
|
19
16
|
export interface ApiFieldError {
|
|
20
17
|
/** Id of the field, as the form names it (the request body key). */
|
|
21
18
|
field: string;
|
|
22
|
-
/**
|
|
23
|
-
* What to show under the field: an i18n key (bare `error.xxx` or `$key`)
|
|
24
|
-
* or prose, resolved with `resolveApiMessage`.
|
|
25
|
-
*/
|
|
19
|
+
/** Server translation key, resolved by the API error presenter. */
|
|
26
20
|
message: string;
|
|
21
|
+
/** Caller key used only when the server's message is not translated. */
|
|
22
|
+
fallbackKey?: string;
|
|
27
23
|
/** The values of the field the error names (addresses of a list…). */
|
|
28
24
|
values?: string[];
|
|
29
25
|
/**
|
|
@@ -325,7 +321,7 @@ function lookupCode<F extends string>(
|
|
|
325
321
|
if (!entry) return undefined;
|
|
326
322
|
return isString(entry)
|
|
327
323
|
? { field: entry, message: code }
|
|
328
|
-
: { field: entry.field, message: entry.message };
|
|
324
|
+
: { field: entry.field, message: code, fallbackKey: entry.message };
|
|
329
325
|
}
|
|
330
326
|
|
|
331
327
|
function fromBody(body: ReturnType<typeof bodyOf>): ApiFieldError[] {
|
|
@@ -489,10 +485,7 @@ export interface HandleApiErrorOptions {
|
|
|
489
485
|
export function useFieldErrors<F extends string>(
|
|
490
486
|
options: UseFieldErrorsOptions<F>,
|
|
491
487
|
) {
|
|
492
|
-
const {
|
|
493
|
-
const toast = useToast();
|
|
494
|
-
const translate = (key: string, params: Parameters<I18nTranslate>[1] = {}) =>
|
|
495
|
-
t(key, params);
|
|
488
|
+
const { presentError, resolveError } = useApiErrorPresenter();
|
|
496
489
|
const fieldIds = Object.keys(options.fields) as F[];
|
|
497
490
|
const errors: Partial<Record<F, string>> = reactive({});
|
|
498
491
|
|
|
@@ -548,21 +541,20 @@ export function useFieldErrors<F extends string>(
|
|
|
548
541
|
async function applyApiError(
|
|
549
542
|
error: Parameters<typeof resolveApiErrorMessage>[0],
|
|
550
543
|
): Promise<boolean> {
|
|
551
|
-
const { fields } = resolveFieldErrors(error, {
|
|
544
|
+
const { fields, hasUnmatched } = resolveFieldErrors(error, {
|
|
552
545
|
fields: fieldIds,
|
|
553
546
|
codes: options.codes,
|
|
554
547
|
});
|
|
555
548
|
if (fields.length === 0) return false;
|
|
556
549
|
clear();
|
|
557
550
|
for (const entry of fields) {
|
|
558
|
-
errors[entry.field as F] =
|
|
559
|
-
|
|
560
|
-
entry.
|
|
561
|
-
|
|
562
|
-
);
|
|
551
|
+
errors[entry.field as F] = resolveError(entry.message, {
|
|
552
|
+
fallbackKey: entry.fallbackKey ?? "dms.field_errors.invalid",
|
|
553
|
+
params: entry.params,
|
|
554
|
+
})?.message;
|
|
563
555
|
}
|
|
564
556
|
await focus(fields[0]!.field as F);
|
|
565
|
-
return
|
|
557
|
+
return !hasUnmatched;
|
|
566
558
|
}
|
|
567
559
|
/* oxlint-enable anti-slop/require-safety-comment-for-type-assertion */
|
|
568
560
|
|
|
@@ -571,12 +563,8 @@ export function useFieldErrors<F extends string>(
|
|
|
571
563
|
error: Parameters<typeof resolveApiErrorMessage>[0],
|
|
572
564
|
toastOptions: HandleApiErrorOptions,
|
|
573
565
|
) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
description: toastOptions.toastDescription
|
|
577
|
-
? t(toastOptions.toastDescription)
|
|
578
|
-
: resolveApiMessage(translate, describeApiError(error)),
|
|
579
|
-
color: "error",
|
|
566
|
+
presentError(error, {
|
|
567
|
+
fallbackKey: toastOptions.toastDescription ?? toastOptions.toastTitle,
|
|
580
568
|
});
|
|
581
569
|
}
|
|
582
570
|
|
|
@@ -48,10 +48,17 @@ type ParamFormatter<Param extends ComposedTextTypedParam> = (
|
|
|
48
48
|
context: ComposedTextContext,
|
|
49
49
|
) => string;
|
|
50
50
|
|
|
51
|
+
type ParamOfType<Type extends ComposedTextParamType> =
|
|
52
|
+
ComposedTextTypedParam extends infer Param
|
|
53
|
+
? Param extends { type: infer Types }
|
|
54
|
+
? Type extends Types
|
|
55
|
+
? Param
|
|
56
|
+
: never
|
|
57
|
+
: never
|
|
58
|
+
: never;
|
|
59
|
+
|
|
51
60
|
type ParamFormatters = {
|
|
52
|
-
[Type in ComposedTextParamType]: ParamFormatter<
|
|
53
|
-
Extract<ComposedTextTypedParam, { type: Type }>
|
|
54
|
-
>;
|
|
61
|
+
[Type in ComposedTextParamType]: ParamFormatter<ParamOfType<Type>>;
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
const DEFAULT_MINOR_DIGITS = 2;
|
|
@@ -162,7 +169,7 @@ function formatParam(
|
|
|
162
169
|
return String(formatNumber(param, context.locale));
|
|
163
170
|
}
|
|
164
171
|
if (isComposedText(param)) return composeText(param, context);
|
|
165
|
-
//
|
|
172
|
+
// SAFETY: ParamFormatters types each entry as the formatter of the variants whose type includes that key, so for a param typed ComposedTextTypedParam the entry under param.type accepts it; the indexed union loses that correlation.
|
|
166
173
|
const formatter = PARAM_FORMATTERS[param.type] as
|
|
167
174
|
| ParamFormatter<ComposedTextTypedParam>
|
|
168
175
|
| undefined;
|
|
@@ -2,11 +2,7 @@ import { isBoolean } from "./type-check";
|
|
|
2
2
|
import type { DataTypeFormatter } from "../composables/data-types/useDataType";
|
|
3
3
|
import { get } from "@nuxt/ui/runtime/utils/index.js";
|
|
4
4
|
import type { RowActionRule, RowActionConfig } from "../types/row-action";
|
|
5
|
-
import {
|
|
6
|
-
FIELD_KEY,
|
|
7
|
-
FIELD_OPERATORS,
|
|
8
|
-
LOGICAL_OPERATOR_KEYS,
|
|
9
|
-
} from "../types/row-action-operators";
|
|
5
|
+
import { FIELD_KEY, FIELD_OPERATORS } from "../types/row-action-operators";
|
|
10
6
|
|
|
11
7
|
type RuleEvaluator = (
|
|
12
8
|
rule: RowActionRule,
|
|
@@ -18,39 +14,15 @@ type RuleDictionary = Record<
|
|
|
18
14
|
Parameters<typeof FIELD_OPERATORS.equals.compare>[0]
|
|
19
15
|
>;
|
|
20
16
|
|
|
21
|
-
const createLogicalEvaluators = (evaluate: RuleEvaluator) => ({
|
|
22
|
-
and: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
|
|
23
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Operator presence does not validate its payload. Owner rule types cannot prove dynamically indexed values; retain the unchecked every call and its existing failures.
|
|
24
|
-
(val as RowActionRule[]).every((r) => evaluate(r, row)),
|
|
25
|
-
or: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
|
|
26
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The existing rule contract assumes an array here without checking it. Inference cannot prove that assumption and guards would change malformed-input failures.
|
|
27
|
-
(val as RowActionRule[]).some((r) => evaluate(r, row)),
|
|
28
|
-
not: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
|
|
29
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Payload is not runtime-validated. Owner types cannot prove this dynamic value; preserve recursive evaluation's existing behavior.
|
|
30
|
-
!evaluate(val as RowActionRule, row),
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
let cachedLogicalEvaluators: ReturnType<typeof createLogicalEvaluators>;
|
|
34
|
-
const getLogicalEvaluators = () => {
|
|
35
|
-
if (!cachedLogicalEvaluators) {
|
|
36
|
-
cachedLogicalEvaluators = createLogicalEvaluators(evaluateRowActionRule);
|
|
37
|
-
}
|
|
38
|
-
return cachedLogicalEvaluators;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
17
|
export const evaluateRowActionRule: RuleEvaluator = (rule, row) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (operator in ruleObj) {
|
|
47
|
-
return logicalEvaluators[operator](ruleObj[operator], row);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
18
|
+
if ("and" in rule)
|
|
19
|
+
return rule.and.every((r) => evaluateRowActionRule(r, row));
|
|
20
|
+
if ("or" in rule) return rule.or.some((r) => evaluateRowActionRule(r, row));
|
|
21
|
+
if ("not" in rule) return !evaluateRowActionRule(rule.not, row);
|
|
50
22
|
|
|
51
|
-
if (FIELD_KEY in
|
|
52
|
-
|
|
53
|
-
const fieldValue = get(row,
|
|
23
|
+
if (FIELD_KEY in rule) {
|
|
24
|
+
const ruleObj: RuleDictionary = rule;
|
|
25
|
+
const fieldValue = get(row, rule.field);
|
|
54
26
|
for (const [operator, def] of Object.entries(FIELD_OPERATORS)) {
|
|
55
27
|
if (operator in ruleObj) {
|
|
56
28
|
const compare = def.compare;
|
|
@@ -4,9 +4,15 @@ interface DmsOAuthProviderDescriptor {
|
|
|
4
4
|
icon: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
interface DmsLoginPrefillConfig {
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
interface DmsPublicConfig {
|
|
8
13
|
baseURL: string;
|
|
9
14
|
homepage?: string;
|
|
15
|
+
loginPrefill?: DmsLoginPrefillConfig;
|
|
10
16
|
mustValidateEmail?: boolean;
|
|
11
17
|
clientBaseUrl?: string;
|
|
12
18
|
/**
|
|
@@ -56,8 +56,9 @@ function closeSheetOnEscape(event: KeyboardEvent): void {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function startHandleDrag(event: PointerEvent): void {
|
|
59
|
-
|
|
60
|
-
(
|
|
59
|
+
const handle = event.currentTarget;
|
|
60
|
+
if (!(handle instanceof HTMLElement)) return;
|
|
61
|
+
handle.setPointerCapture(event.pointerId);
|
|
61
62
|
const renderedWidth = panelEl.value?.getBoundingClientRect().width;
|
|
62
63
|
startDrag(event, renderedWidth || width.value);
|
|
63
64
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { SelectItem } from "@nuxt/ui";
|
|
3
|
+
import { GITHUB_REPOSITORY_URL } from "../../utils/github-repository";
|
|
3
4
|
|
|
4
5
|
const EXTERNAL_LINK_PATTERN = /^https?:\/\//;
|
|
5
6
|
const DEFAULT_COPYRIGHT = "Antelope";
|
|
@@ -65,6 +66,19 @@ async function onLocaleChange(code: Parameters<typeof changeLanguage>[0]) {
|
|
|
65
66
|
</DmsLink>
|
|
66
67
|
</template>
|
|
67
68
|
|
|
69
|
+
<a
|
|
70
|
+
:href="GITHUB_REPOSITORY_URL"
|
|
71
|
+
target="_blank"
|
|
72
|
+
rel="noopener noreferrer"
|
|
73
|
+
:aria-label="t('empty_layout.footer.github_star_label')"
|
|
74
|
+
:title="t('empty_layout.footer.github_star_label')"
|
|
75
|
+
class="border-default text-muted hover:text-highlighted hover:border-accented hover:bg-elevated inline-flex h-6 items-center gap-1.5 rounded-md border px-2 font-medium transition-colors max-sm:h-8"
|
|
76
|
+
>
|
|
77
|
+
<UIcon name="i-ph-github-logo" class="size-3.5" aria-hidden="true" />
|
|
78
|
+
<UIcon name="i-ph-star" class="size-3" aria-hidden="true" />
|
|
79
|
+
{{ t("empty_layout.footer.github_star") }}
|
|
80
|
+
</a>
|
|
81
|
+
|
|
68
82
|
<USelect
|
|
69
83
|
v-if="showsLocaleSwitcher"
|
|
70
84
|
:model-value="locale"
|
|
@@ -75,13 +75,15 @@ const { quickActions } = siteLayout;
|
|
|
75
75
|
const route = useDmsRoute();
|
|
76
76
|
const router = useDmsRouter();
|
|
77
77
|
const { $authFetch } = useAuthFetch();
|
|
78
|
+
const { presentError } = useApiErrorPresenter();
|
|
78
79
|
|
|
79
80
|
const { handleCustomButton } = useActionTargets({
|
|
80
81
|
api: $authFetch,
|
|
81
82
|
componentId: "page-header",
|
|
82
83
|
pageId: siteLayout.findMatchingRoute(route.path)?.metadata?.fullId ?? "page",
|
|
83
84
|
refreshCallback: refreshPageBlocks,
|
|
84
|
-
handleApiError: (error, title) =>
|
|
85
|
+
handleApiError: (error, title) =>
|
|
86
|
+
presentError(error, { fallbackKey: title ?? "error.500.description" }),
|
|
85
87
|
});
|
|
86
88
|
|
|
87
89
|
function pressComponentButton(componentId: string, buttonId?: string) {
|