@antelopejs/dms 0.12.1 → 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 +28 -0
- package/data/dbip-country-lite.mmdb +0 -0
- package/data/module-store/catalog.json +0 -6
- package/dist/index.js +13 -3
- package/dist/index.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 -14
- 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-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 +0 -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 -11
- package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
- package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -29
- 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 +4 -4
- 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/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 +0 -4
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +0 -4
- 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 +13 -2
- 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 -10
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
- 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 +7 -10
- 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 +4 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -18
- 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/ChartCard.vue +3 -2
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -19
- package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
- 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 -11
- 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 -25
- 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 +1 -3
- 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 +10 -4
- package/skills/dms-module/REFERENCE.md +3 -1
- package/skills/dms-module/SKILL.md +7 -2
- package/skills/dms-project/SKILL.md +9 -3
- package/frontend-vue/layers/dms-core/app/composables/useReadOnlyRefusal.ts +0 -49
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
getCurrentInstance,
|
|
4
|
+
onMounted,
|
|
5
|
+
shallowRef,
|
|
6
|
+
type Ref,
|
|
7
|
+
} from "vue";
|
|
2
8
|
|
|
3
9
|
/** How keyboard hints are printed: macOS/iOS symbols, or PC key names. */
|
|
4
10
|
export type KeyboardPlatform = "mac" | "other";
|
|
@@ -10,6 +16,12 @@ export type KeyboardPlatform = "mac" | "other";
|
|
|
10
16
|
*/
|
|
11
17
|
export const KEYBOARD_PLATFORM_COOKIE = "dms-keyboard-platform";
|
|
12
18
|
const KEYBOARD_PLATFORM_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
19
|
+
/**
|
|
20
|
+
* The platform the server rendered the page's hints with, handed to the
|
|
21
|
+
* browser with the page: the cookie may change before every hint has
|
|
22
|
+
* hydrated.
|
|
23
|
+
*/
|
|
24
|
+
const RENDERED_KEYBOARD_PLATFORM_STATE = "dms-keyboard-platform-rendered";
|
|
13
25
|
/** What the server renders before the browser has said otherwise. */
|
|
14
26
|
export const DEFAULT_KEYBOARD_PLATFORM: KeyboardPlatform = "other";
|
|
15
27
|
|
|
@@ -91,12 +103,17 @@ function normalizePlatform(value: KeyboardPlatform): KeyboardPlatform {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
/**
|
|
94
|
-
* The keyboard platform every hint renders with, shared by all callers.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
106
|
+
* The keyboard platform every hint renders with, shared by all callers.
|
|
107
|
+
*
|
|
108
|
+
* The server renders the cookie's platform and hands it to the browser with
|
|
109
|
+
* the page. A component the browser creates while it hydrates that page
|
|
110
|
+
* (`isDmsHydrating()`) renders it too, until its own `onMounted`: the first
|
|
111
|
+
* hint to mount corrects the cookie, and a hint hydrating after it (an async
|
|
112
|
+
* component) must still draw what the server drew. Once mounted, the browser
|
|
113
|
+
* detects its platform and, if the cookie was wrong or missing, corrects it,
|
|
114
|
+
* which re-renders the hints in place and lets the next server render get
|
|
115
|
+
* them right. Only labels follow it: the key bindings themselves stay with
|
|
116
|
+
* `defineShortcuts`.
|
|
100
117
|
*/
|
|
101
118
|
export function useKeyboardPlatform() {
|
|
102
119
|
const stored: Ref<KeyboardPlatform> = useDmsCookie<KeyboardPlatform>(
|
|
@@ -106,13 +123,23 @@ export function useKeyboardPlatform() {
|
|
|
106
123
|
maxAge: KEYBOARD_PLATFORM_COOKIE_MAX_AGE,
|
|
107
124
|
},
|
|
108
125
|
);
|
|
109
|
-
|
|
126
|
+
const rendered = useDmsState<KeyboardPlatform>(
|
|
127
|
+
RENDERED_KEYBOARD_PLATFORM_STATE,
|
|
128
|
+
() => normalizePlatform(stored.value),
|
|
129
|
+
{ transfer: true },
|
|
130
|
+
);
|
|
131
|
+
const inComponent = getCurrentInstance() !== null;
|
|
132
|
+
const hydrating = shallowRef(inComponent && isDmsHydrating());
|
|
133
|
+
if (inComponent) {
|
|
110
134
|
onMounted(() => {
|
|
135
|
+
hydrating.value = false;
|
|
111
136
|
const detected = detectKeyboardPlatform(window.navigator);
|
|
112
137
|
if (stored.value !== detected) stored.value = detected;
|
|
113
138
|
});
|
|
114
139
|
}
|
|
115
|
-
const platform = computed(() =>
|
|
140
|
+
const platform = computed(() =>
|
|
141
|
+
normalizePlatform(hydrating.value ? rendered.value : stored.value),
|
|
142
|
+
);
|
|
116
143
|
return {
|
|
117
144
|
platform,
|
|
118
145
|
isMac: computed(() => platform.value === "mac"),
|
|
@@ -94,7 +94,7 @@ function createExportToasts(
|
|
|
94
94
|
) {
|
|
95
95
|
const toast = useToast();
|
|
96
96
|
const { t } = useI18n();
|
|
97
|
-
const {
|
|
97
|
+
const { presentError } = useApiErrorPresenter();
|
|
98
98
|
const { $authFetch } = useAuthFetch();
|
|
99
99
|
|
|
100
100
|
function resolveLabels(
|
|
@@ -152,14 +152,13 @@ function createExportToasts(
|
|
|
152
152
|
|
|
153
153
|
function buildRetryActions(
|
|
154
154
|
opts: RunExportJobOptions,
|
|
155
|
-
labels: UseExportJobLabels,
|
|
156
155
|
getCurrentToastId: () => string | number,
|
|
157
|
-
):
|
|
156
|
+
): ApiErrorAction[] | undefined {
|
|
158
157
|
if (opts.retryable === false) return undefined;
|
|
159
158
|
return [
|
|
160
159
|
{
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
labelKey: opts.labels?.retry ?? "dms.table.export.retry",
|
|
161
|
+
onSelect: () => {
|
|
163
162
|
toast.remove(getCurrentToastId());
|
|
164
163
|
void runJob(opts);
|
|
165
164
|
},
|
|
@@ -170,39 +169,13 @@ function createExportToasts(
|
|
|
170
169
|
function showFailure(
|
|
171
170
|
toastId: string | number,
|
|
172
171
|
opts: RunExportJobOptions,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
icon: "i-ph-warning",
|
|
180
|
-
title: labels.errorTitle,
|
|
181
|
-
description: error ? processApiMessage(error) : labels.fallbackErrorTitle,
|
|
182
|
-
duration: EXPORT_TOAST_DURATION,
|
|
183
|
-
progress: true,
|
|
184
|
-
actions: buildRetryActions(opts, labels, () => newId),
|
|
172
|
+
error: unknown,
|
|
173
|
+
): void {
|
|
174
|
+
presentError(error, {
|
|
175
|
+
fallbackKey: opts.labels?.errorTitle ?? "dms.table.export.error_title",
|
|
176
|
+
replaceToastId: toastId,
|
|
177
|
+
actions: buildRetryActions(opts, () => toastId),
|
|
185
178
|
});
|
|
186
|
-
return newId;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function showError(
|
|
190
|
-
toastId: string | number,
|
|
191
|
-
opts: RunExportJobOptions,
|
|
192
|
-
labels: UseExportJobLabels,
|
|
193
|
-
error: Error,
|
|
194
|
-
): string | number {
|
|
195
|
-
let newId: string | number = toastId;
|
|
196
|
-
newId = replaceProgressToast(toastId, {
|
|
197
|
-
color: Color.error,
|
|
198
|
-
icon: "i-ph-warning",
|
|
199
|
-
title: labels.fallbackErrorTitle,
|
|
200
|
-
description: processApiMessage(error.message),
|
|
201
|
-
duration: EXPORT_TOAST_DURATION,
|
|
202
|
-
progress: true,
|
|
203
|
-
actions: buildRetryActions(opts, labels, () => newId),
|
|
204
|
-
});
|
|
205
|
-
return newId;
|
|
206
179
|
}
|
|
207
180
|
|
|
208
181
|
return {
|
|
@@ -212,7 +185,6 @@ function createExportToasts(
|
|
|
212
185
|
updateProgressToast,
|
|
213
186
|
showSuccess,
|
|
214
187
|
showFailure,
|
|
215
|
-
showError,
|
|
216
188
|
};
|
|
217
189
|
}
|
|
218
190
|
|
|
@@ -352,25 +324,10 @@ async function readExportStream(
|
|
|
352
324
|
return new Blob(chunks as BlobPart[]);
|
|
353
325
|
}
|
|
354
326
|
|
|
355
|
-
function getErrorMessage(
|
|
356
|
-
error: Parameters<typeof apiErrorText>[0],
|
|
357
|
-
): string | undefined {
|
|
358
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- These fields are not validated as strings; preserve optional property access, getter order, and truthy non-string forwarding for malformed errors.
|
|
359
|
-
const err = error as { data?: string; message?: string };
|
|
360
|
-
const data = err?.data;
|
|
361
|
-
const message = err?.message;
|
|
362
|
-
return data || message;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
327
|
export const useExportJob = () => {
|
|
366
328
|
const context = createExportToasts((opts) => runJob(opts));
|
|
367
|
-
const {
|
|
368
|
-
|
|
369
|
-
createExportToast,
|
|
370
|
-
showSuccess,
|
|
371
|
-
showFailure,
|
|
372
|
-
showError,
|
|
373
|
-
} = context;
|
|
329
|
+
const { resolveLabels, createExportToast, showSuccess, showFailure } =
|
|
330
|
+
context;
|
|
374
331
|
const {
|
|
375
332
|
startJob,
|
|
376
333
|
resolveStatusUrl,
|
|
@@ -404,10 +361,7 @@ export const useExportJob = () => {
|
|
|
404
361
|
);
|
|
405
362
|
showSuccess(exportToast.id, labels);
|
|
406
363
|
} catch (error) {
|
|
407
|
-
|
|
408
|
-
if (message) showFailure(exportToast.id, opts, labels, message);
|
|
409
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Rejections are not checked as Error; retain showError's existing message access and failure behavior for malformed values.
|
|
410
|
-
else showError(exportToast.id, opts, labels, error as Error);
|
|
364
|
+
showFailure(exportToast.id, opts, error);
|
|
411
365
|
}
|
|
412
366
|
}
|
|
413
367
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useKeyboardPlatform } from "../composables/global/keyboardPlatform";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nuxt UI's key hints (`<UKbd value="meta">`, the `kbds` of its menus, the
|
|
5
|
+
* dashboard search button) print `meta`, `ctrl` and `alt` for the keyboard
|
|
6
|
+
* platform the dashboard's own hints use: the cookie's, read in each hint's
|
|
7
|
+
* setup, so a hint hydrating the server's page keeps the platform the server
|
|
8
|
+
* printed until it is mounted (`useKeyboardPlatform`). Without it they print
|
|
9
|
+
* a blank on the server and fill it in once mounted.
|
|
10
|
+
*/
|
|
11
|
+
export default defineDmsPlugin(({ vueApp }) => {
|
|
12
|
+
provideDmsKbdPlatform(vueApp, () => useKeyboardPlatform().platform);
|
|
13
|
+
});
|
|
@@ -748,6 +748,7 @@
|
|
|
748
748
|
"not_found_hint": "What this shows doesn’t exist, or no longer does."
|
|
749
749
|
},
|
|
750
750
|
"action_error": {
|
|
751
|
+
"fields": "{summary}",
|
|
751
752
|
"forbidden": "You don’t have permission to do this.",
|
|
752
753
|
"generic": "Something went wrong and the action couldn’t be completed. Try again."
|
|
753
754
|
},
|
|
@@ -748,6 +748,7 @@
|
|
|
748
748
|
"not_found_hint": "Ce qui est affiché ici n’existe pas, ou plus."
|
|
749
749
|
},
|
|
750
750
|
"action_error": {
|
|
751
|
+
"fields": "{summary}",
|
|
751
752
|
"forbidden": "Vous n’avez pas l’autorisation d’effectuer cette action.",
|
|
752
753
|
"generic": "Une erreur est survenue et l’action n’a pas pu aboutir. Réessayez."
|
|
753
754
|
},
|
|
@@ -18,9 +18,7 @@
|
|
|
18
18
|
"test": "vitest run",
|
|
19
19
|
"format": "prettier --write .",
|
|
20
20
|
"lint": "node scripts/check-invariants.mjs && node scripts/check-disable-blocks.mjs && eslint .",
|
|
21
|
-
"lint:fix": "eslint . --fix"
|
|
22
|
-
"frontend:build": "ajs dms build -b http://127.0.0.1:5010",
|
|
23
|
-
"frontend:start": "ajs dms start -p 3001"
|
|
21
|
+
"lint:fix": "eslint . --fix"
|
|
24
22
|
},
|
|
25
23
|
"dependencies": {
|
|
26
24
|
"@codemirror/autocomplete": "^6.18.6",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const PRESENTER = fileURLToPath(
|
|
7
|
+
new URL(
|
|
8
|
+
"../layers/dms-core/app/composables/useApiErrorPresenter.ts",
|
|
9
|
+
import.meta.url,
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
const SKIP = new Set(["node_modules", "dist", "tests", "test", "fixtures"]);
|
|
13
|
+
|
|
14
|
+
function dependencies(root) {
|
|
15
|
+
let directory = resolve(root);
|
|
16
|
+
if (!statSync(directory).isDirectory()) directory = dirname(directory);
|
|
17
|
+
while (!existsSync(join(directory, "package.json"))) {
|
|
18
|
+
const parent = dirname(directory);
|
|
19
|
+
if (parent === directory) throw new Error(`No package.json above ${root}`);
|
|
20
|
+
directory = parent;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const require = createRequire(join(directory, "package.json"));
|
|
24
|
+
return {
|
|
25
|
+
ts: require("typescript"),
|
|
26
|
+
parse: require("vue/compiler-sfc").parse,
|
|
27
|
+
};
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Install typescript and vue in ${directory} to check API error presentation`,
|
|
31
|
+
{ cause: error },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runtime sources only. Test fixtures and generated directories are excluded. */
|
|
37
|
+
export function apiErrorSources(root) {
|
|
38
|
+
if (!statSync(root).isDirectory()) return [resolve(root)];
|
|
39
|
+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
40
|
+
if (entry.name.startsWith(".") || SKIP.has(entry.name)) return [];
|
|
41
|
+
const path = join(root, entry.name);
|
|
42
|
+
if (entry.isDirectory()) return apiErrorSources(path);
|
|
43
|
+
return /\.(?:vue|[cm]?tsx?)$/.test(entry.name) &&
|
|
44
|
+
!/\.(?:test|spec|d)\.ts$/.test(entry.name)
|
|
45
|
+
? [resolve(path)]
|
|
46
|
+
: [];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parses both Vue script blocks without treating template text as JavaScript. */
|
|
51
|
+
export function apiErrorScripts(file, source, tools) {
|
|
52
|
+
if (!file.endsWith(".vue")) return [{ source, offset: 0 }];
|
|
53
|
+
const { descriptor, errors } = tools.parse(source, { filename: file });
|
|
54
|
+
if (errors.length)
|
|
55
|
+
throw new Error(`Cannot parse ${file}: ${errors.join(", ")}`);
|
|
56
|
+
return [descriptor.script, descriptor.scriptSetup]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((block) => ({
|
|
59
|
+
source: block.content,
|
|
60
|
+
offset: block.loc.start.offset,
|
|
61
|
+
isSetup: block === descriptor.scriptSetup,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bindingsOf(tree, ts) {
|
|
66
|
+
const bindings = new Map();
|
|
67
|
+
const visit = (node) => {
|
|
68
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
69
|
+
if (ts.isIdentifier(node.name))
|
|
70
|
+
bindings.set(node.name.text, node.initializer);
|
|
71
|
+
if (ts.isObjectBindingPattern(node.name)) {
|
|
72
|
+
for (const item of node.name.elements) {
|
|
73
|
+
if (ts.isIdentifier(item.name))
|
|
74
|
+
bindings.set(item.name.text, {
|
|
75
|
+
binding: item.propertyName?.getText(tree) ?? item.name.text,
|
|
76
|
+
owner: node.initializer,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
ts.forEachChild(node, visit);
|
|
82
|
+
};
|
|
83
|
+
visit(tree);
|
|
84
|
+
return bindings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Analyzes local toast bindings and failure regions. Cross-file effects are not inferred. */
|
|
88
|
+
export function analyzeApiErrorScript(source, file, ts) {
|
|
89
|
+
const tree = ts.createSourceFile(
|
|
90
|
+
file,
|
|
91
|
+
source,
|
|
92
|
+
ts.ScriptTarget.Latest,
|
|
93
|
+
true,
|
|
94
|
+
ts.ScriptKind.TS,
|
|
95
|
+
);
|
|
96
|
+
const bindings = bindingsOf(tree, ts);
|
|
97
|
+
const unwrap = (node) => {
|
|
98
|
+
while (
|
|
99
|
+
node &&
|
|
100
|
+
(ts.isParenthesizedExpression(node) ||
|
|
101
|
+
ts.isAsExpression(node) ||
|
|
102
|
+
ts.isSatisfiesExpression(node) ||
|
|
103
|
+
ts.isNonNullExpression(node))
|
|
104
|
+
)
|
|
105
|
+
node = node.expression;
|
|
106
|
+
return node;
|
|
107
|
+
};
|
|
108
|
+
const resolveBinding = (node, seen = new Set()) => {
|
|
109
|
+
node = unwrap(node);
|
|
110
|
+
if (
|
|
111
|
+
node &&
|
|
112
|
+
ts.isIdentifier(node) &&
|
|
113
|
+
bindings.has(node.text) &&
|
|
114
|
+
!seen.has(node.text)
|
|
115
|
+
) {
|
|
116
|
+
seen.add(node.text);
|
|
117
|
+
const next = bindings.get(node.text);
|
|
118
|
+
if (!next.binding) return resolveBinding(next, seen);
|
|
119
|
+
}
|
|
120
|
+
return node;
|
|
121
|
+
};
|
|
122
|
+
const isToast = (node) => {
|
|
123
|
+
node = resolveBinding(node);
|
|
124
|
+
return (
|
|
125
|
+
node &&
|
|
126
|
+
((ts.isIdentifier(node) && node.text === "toast") ||
|
|
127
|
+
(ts.isCallExpression(node) &&
|
|
128
|
+
node.expression.getText(tree) === "useToast"))
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
const methodOf = (call) => {
|
|
132
|
+
const expression = unwrap(call.expression);
|
|
133
|
+
if (
|
|
134
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
135
|
+
isToast(expression.expression) &&
|
|
136
|
+
["add", "update"].includes(expression.name.text)
|
|
137
|
+
)
|
|
138
|
+
return expression.name.text;
|
|
139
|
+
if (ts.isIdentifier(expression)) {
|
|
140
|
+
const binding = bindings.get(expression.text);
|
|
141
|
+
if (
|
|
142
|
+
binding?.binding &&
|
|
143
|
+
isToast(binding.owner) &&
|
|
144
|
+
["add", "update"].includes(binding.binding)
|
|
145
|
+
)
|
|
146
|
+
return binding.binding;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
150
|
+
const colorOf = (payload) => {
|
|
151
|
+
payload = resolveBinding(payload);
|
|
152
|
+
if (!payload || !ts.isObjectLiteralExpression(payload)) return "dynamic";
|
|
153
|
+
if (payload.properties.some(ts.isSpreadAssignment)) return "dynamic";
|
|
154
|
+
const color = payload.properties.find(
|
|
155
|
+
(property) =>
|
|
156
|
+
property.name?.getText(tree).replace(/["']/g, "") === "color",
|
|
157
|
+
);
|
|
158
|
+
if (!color) return "neutral";
|
|
159
|
+
const value = resolveBinding(color.initializer);
|
|
160
|
+
if (!value) return "dynamic";
|
|
161
|
+
if (ts.isStringLiteral(value)) return value.text;
|
|
162
|
+
if (
|
|
163
|
+
ts.isPropertyAccessExpression(value) &&
|
|
164
|
+
value.expression.getText(tree) === "Color"
|
|
165
|
+
)
|
|
166
|
+
return value.name.text;
|
|
167
|
+
return "dynamic";
|
|
168
|
+
};
|
|
169
|
+
const calls = [];
|
|
170
|
+
const visit = (node, failure = null) => {
|
|
171
|
+
if (ts.isCatchClause(node)) {
|
|
172
|
+
visit(node.block, node.variableDeclaration?.name.getText(tree) ?? "");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (ts.isCallExpression(node)) {
|
|
176
|
+
const method = methodOf(node);
|
|
177
|
+
if (method) {
|
|
178
|
+
const payload = node.arguments[method === "update" ? 1 : 0];
|
|
179
|
+
const color = colorOf(payload);
|
|
180
|
+
if (color === "error" || (failure !== null && color !== "success"))
|
|
181
|
+
calls.push({ node, payload, method, failure, color, tree });
|
|
182
|
+
}
|
|
183
|
+
const expression = unwrap(node.expression);
|
|
184
|
+
if (
|
|
185
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
186
|
+
["catch", "then"].includes(expression.name.text)
|
|
187
|
+
) {
|
|
188
|
+
const rejectionIndex = expression.name.text === "catch" ? 0 : 1;
|
|
189
|
+
visit(expression, failure);
|
|
190
|
+
node.arguments.forEach((argument, index) => {
|
|
191
|
+
const rejected =
|
|
192
|
+
index === rejectionIndex &&
|
|
193
|
+
(ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
|
|
194
|
+
visit(
|
|
195
|
+
argument,
|
|
196
|
+
rejected
|
|
197
|
+
? (argument.parameters[0]?.name.getText(tree) ?? "")
|
|
198
|
+
: failure,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(node, (child) => visit(child, failure));
|
|
205
|
+
};
|
|
206
|
+
visit(tree);
|
|
207
|
+
return calls;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Returns diagnostics for local failure toasts outside the canonical presenter. */
|
|
211
|
+
export function checkApiErrorPresentation(roots) {
|
|
212
|
+
return roots.flatMap((root) => {
|
|
213
|
+
const tools = dependencies(root);
|
|
214
|
+
return apiErrorSources(resolve(root)).flatMap((file) => {
|
|
215
|
+
if (file === PRESENTER) return [];
|
|
216
|
+
const source = readFileSync(file, "utf8");
|
|
217
|
+
return apiErrorScripts(file, source, tools).flatMap((script) =>
|
|
218
|
+
analyzeApiErrorScript(script.source, file, tools.ts).map(({ node }) => {
|
|
219
|
+
const offset = script.offset + node.getStart();
|
|
220
|
+
const line = source.slice(0, offset).split("\n").length;
|
|
221
|
+
return `${file}:${line}: Use useApiErrorPresenter().presentError(error, { fallbackKey })`;
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Runs the checker with development dependencies resolved from each frontend root. */
|
|
229
|
+
export function assertApiErrorPresentation(roots) {
|
|
230
|
+
const diagnostics = checkApiErrorPresentation(roots);
|
|
231
|
+
if (diagnostics.length) throw new Error(diagnostics.join("\n"));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
process.argv[1] &&
|
|
236
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
237
|
+
) {
|
|
238
|
+
const roots = process.argv.slice(2);
|
|
239
|
+
if (!roots.length)
|
|
240
|
+
throw new Error("Pass frontend app or layers directories to check");
|
|
241
|
+
assertApiErrorPresentation(roots);
|
|
242
|
+
}
|
|
@@ -4,6 +4,8 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { brotliDecompressSync } from "node:zlib";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { assertApiErrorPresentation } from "./check-api-error-presentation.mjs";
|
|
8
|
+
import "./test-api-error-presentation.mjs";
|
|
7
9
|
import "./check-autoimport-boundaries.mjs";
|
|
8
10
|
import "./test-autoimport-boundaries.mjs";
|
|
9
11
|
import "./check-page-invariants.mjs";
|
|
@@ -12,6 +14,25 @@ import "../../../../checks/notification-tone-contract.mjs";
|
|
|
12
14
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
15
|
const LAYERS_DIR = join(ROOT, "layers");
|
|
14
16
|
|
|
17
|
+
function checkCoreErrorFallback() {
|
|
18
|
+
for (const locale of ["en-GB", "fr-FR"]) {
|
|
19
|
+
const messages = JSON.parse(
|
|
20
|
+
readFileSync(
|
|
21
|
+
join(LAYERS_DIR, `dms-core/i18n/locales/core-${locale}.json`),
|
|
22
|
+
"utf8",
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
assert.ok(
|
|
26
|
+
messages.error["500"].description.trim(),
|
|
27
|
+
`${locale}: always load error.500.description`,
|
|
28
|
+
);
|
|
29
|
+
assert.ok(
|
|
30
|
+
messages.error.read_only_demo.trim(),
|
|
31
|
+
`${locale}: translate error.read_only_demo`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
function collectSources(dir) {
|
|
16
37
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
17
38
|
const file = join(dir, entry.name);
|
|
@@ -334,6 +355,8 @@ function checkGeistNarrowSpace() {
|
|
|
334
355
|
}
|
|
335
356
|
|
|
336
357
|
for (const [name, check] of [
|
|
358
|
+
["API error presentation", () => assertApiErrorPresentation([LAYERS_DIR])],
|
|
359
|
+
["core error fallback", checkCoreErrorFallback],
|
|
337
360
|
["translated accessible names", checkAccessibleNames],
|
|
338
361
|
["layer plugin registration", checkPluginRegistration],
|
|
339
362
|
["compare mode labels", checkCompareModeLabels],
|