@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/data/dbip-country-lite.mmdb +0 -0
  3. package/data/module-store/catalog.json +0 -6
  4. package/dist/index.js +13 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/utils/ensure-default-tenant.js +7 -2
  7. package/dist/utils/ensure-default-tenant.js.map +1 -1
  8. package/frontend-vue/dms.frontend.ts +2 -0
  9. package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
  10. package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -14
  11. package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
  12. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
  13. package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
  14. package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
  15. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +0 -2
  16. package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
  17. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -11
  18. package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
  19. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -29
  20. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
  21. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
  22. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +4 -4
  23. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
  24. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
  25. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
  26. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
  27. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
  28. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
  29. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
  30. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
  31. package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
  32. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
  33. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
  34. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +0 -4
  35. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +0 -4
  36. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
  38. package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +13 -2
  39. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
  40. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -10
  41. package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +7 -10
  46. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
  47. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +4 -1
  48. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -18
  49. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
  50. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
  51. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
  52. package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
  54. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
  55. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -19
  56. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
  57. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
  58. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
  59. package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
  60. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
  61. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
  63. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
  64. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -11
  65. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
  66. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -25
  67. package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
  68. package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
  69. package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
  70. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  71. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  72. package/frontend-vue/package.json +1 -3
  73. package/frontend-vue/scripts/check-api-error-presentation.mjs +242 -0
  74. package/frontend-vue/scripts/check-invariants.mjs +23 -0
  75. package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
  76. package/frontend-vue/scripts/test-api-error-presentation.mjs +93 -0
  77. package/package.json +10 -4
  78. package/skills/dms-module/REFERENCE.md +3 -1
  79. package/skills/dms-module/SKILL.md +7 -2
  80. package/skills/dms-project/SKILL.md +9 -3
  81. package/frontend-vue/layers/dms-core/app/composables/useReadOnlyRefusal.ts +0 -49
@@ -1,4 +1,3 @@
1
- import { isString } from "../../../../dms-core/app/utils/type-check";
2
1
  import type { Ref, VNode } from "vue";
3
2
  import ModuleStoreDependencyPicker from "../../build/components/pages/modules/ModuleStoreDependencyPicker.vue";
4
3
  import {
@@ -22,12 +21,6 @@ import type {
22
21
  } from "#dms-core/app/types/module-store";
23
22
  import type { useModuleStore } from "./useModuleStore";
24
23
 
25
- /** What a refused store request carries: the server's error key, if any. */
26
- interface StoreRequestError {
27
- data?: unknown;
28
- message?: string;
29
- }
30
-
31
24
  /** What the frontend banner says while it lags behind the backend. */
32
25
  export interface StoreFrontendNotice {
33
26
  key: "dev" | "production" | "unknown";
@@ -65,18 +58,9 @@ export const useModuleStoreActions = (
65
58
  ) => {
66
59
  const { t } = useI18n();
67
60
  const { processI18n } = useTranslation();
68
- const toast = useToast();
61
+ const { presentError } = useApiErrorPresenter();
69
62
  const { confirm } = useConfirm();
70
63
 
71
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Store requests can reject with arbitrary JavaScript values; the store's Promise contract has no rejection owner type.
72
- function resolveStoreError(error: unknown): string {
73
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- This existing unchecked optional-property view is not validation of a StoreRequestError; inference cannot establish that rejected values have this shape, and parsing would change malformed-error behavior.
74
- const details = error as StoreRequestError | undefined;
75
- const key = isString(details?.data) ? details.data : undefined;
76
- if (!key) return details?.message ?? "";
77
- return t(`modules.store.errors.${key.replace("error.", "")}`);
78
- }
79
-
80
64
  /**
81
65
  * Runs `kind` from a dialog: a refusal is worded as the toast words it and
82
66
  * shown in the dialog, which stays open.
@@ -89,12 +73,15 @@ export const useModuleStoreActions = (
89
73
  try {
90
74
  await moduleStore.run(kind, entry, body);
91
75
  } catch (error) {
92
- throw new ConfirmActionError({
93
- title: t("modules.store.action_failed", {
94
- module: processI18n(entry.title),
95
- }),
96
- description: resolveStoreError(error),
97
- });
76
+ throw new ConfirmActionError(
77
+ {
78
+ fallbackKey: "modules.store.action_failed",
79
+ params: {
80
+ module: processI18n(entry.title),
81
+ },
82
+ },
83
+ { cause: error },
84
+ );
98
85
  }
99
86
  }
100
87
 
@@ -165,11 +152,9 @@ export const useModuleStoreActions = (
165
152
  try {
166
153
  await moduleStore.run(kind, entry);
167
154
  } catch (error) {
168
- toast.add({
169
- color: "error",
170
- icon: "i-ph-warning-circle",
171
- title: t("modules.store.action_failed", { module: title }),
172
- description: resolveStoreError(error),
155
+ presentError(error, {
156
+ fallbackKey: "modules.store.action_failed",
157
+ params: { module: title },
173
158
  });
174
159
  }
175
160
  }
@@ -178,10 +178,10 @@ const skeletonItems = Array.from(
178
178
  while the frontend restarts keeps the tiles and tries again. -->
179
179
  <DmsBanner
180
180
  v-else-if="moduleStore.loadingError.value && !storeListing"
181
- tone="error"
181
+ :tone="moduleStore.loadingNotice.value?.severity ?? 'error'"
182
182
  size="sm"
183
183
  icon="i-ph-plugs"
184
- :title="t('modules.store.load_error')"
184
+ :title="moduleStore.loadingNotice.value?.message"
185
185
  >
186
186
  <template #actions>
187
187
  <UButton
@@ -26,6 +26,8 @@ import { REQUIRED_MESSAGE } from "#dms-core/app/composables/useFormValidation";
26
26
  import { useUnsavedChanges } from "#dms-ui/app/composables/unsaved-changes/useUnsavedChanges";
27
27
  import { useActionConfirm } from "#dms-ui/app/entries/composables/confirm/useActionConfirm";
28
28
 
29
+ const { presentError } = useApiErrorPresenter();
30
+
29
31
  interface RoleEditorHandle {
30
32
  inputOf: (field: RoleEditorField) => HTMLInputElement | undefined;
31
33
  }
@@ -152,7 +154,7 @@ async function runMutation(
152
154
  await mutation();
153
155
  toast.add({ title: t(successKey), color: "success" });
154
156
  } catch (error) {
155
- useApiError(error);
157
+ presentError(error, { fallbackKey: "error.500.description" });
156
158
  }
157
159
  }
158
160
 
@@ -181,7 +183,8 @@ async function save(): Promise<void> {
181
183
  color: "success",
182
184
  });
183
185
  } catch (error) {
184
- if (!(await fieldErrors.applyApiError(error))) useApiError(error);
186
+ if (!(await fieldErrors.applyApiError(error)))
187
+ presentError(error, { fallbackKey: "error.500.description" });
185
188
  } finally {
186
189
  isSaving.value = false;
187
190
  }
@@ -34,6 +34,7 @@ export default defineDmsPlugin(() => {
34
34
  const { $i18n: i18n } = useDmsApp();
35
35
  const { t } = getAppTranslation();
36
36
  const toast = useToast();
37
+ const { presentError } = useApiErrorPresenter();
37
38
  const { logout } = useLogout();
38
39
  const { changeLanguage } = useUserLanguage();
39
40
  const { copy } = useClipboard({ legacy: true });
@@ -51,10 +52,9 @@ export default defineDmsPlugin(() => {
51
52
  try {
52
53
  await copy(window.location.href);
53
54
  toast.add({ title: t("commandPalette.session.urlCopied") });
54
- } catch {
55
- toast.add({
56
- title: t("commandPalette.session.copyFailed"),
57
- color: "error",
55
+ } catch (error) {
56
+ presentError(error, {
57
+ fallbackKey: "commandPalette.session.copyFailed",
58
58
  });
59
59
  }
60
60
  }
@@ -875,10 +875,6 @@
875
875
  "title": "Builder",
876
876
  "description": "Edits the project’s pages, tables and forms, writing only code that compiles."
877
877
  },
878
- "cicd": {
879
- "title": "Publishing",
880
- "description": "Versions, environments and deploys of your project through git."
881
- },
882
878
  "database": {
883
879
  "title": "Database",
884
880
  "description": "Schemas, data browser, schema diagram and an AQL query workspace."
@@ -875,10 +875,6 @@
875
875
  "title": "Builder",
876
876
  "description": "Modifie les pages, tableaux et formulaires du projet, en n’écrivant que du code qui compile."
877
877
  },
878
- "cicd": {
879
- "title": "Publication",
880
- "description": "Versions, environnements et déploiements de votre projet via git."
881
- },
882
878
  "database": {
883
879
  "title": "Base de données",
884
880
  "description": "Schémas, explorateur de données, diagramme et espace de requêtes AQL."
@@ -45,6 +45,7 @@ const VARIANTS = {
45
45
  } as const;
46
46
 
47
47
  const { t } = useI18n();
48
+ const { resolveError } = useApiErrorPresenter();
48
49
  const { processI18n } = useTranslation();
49
50
 
50
51
  const translate = (text: string | undefined) =>
@@ -52,7 +53,7 @@ const translate = (text: string | undefined) =>
52
53
 
53
54
  const notFoundState = computed(() => {
54
55
  const answered = props.notFound?.emptyState ?? {};
55
- const defaults = notFoundTexts(props.notFound, t);
56
+ const defaults = notFoundTexts(props.notFound, t, resolveError);
56
57
  return {
57
58
  title: translate(answered.title) ?? defaults.title,
58
59
  description: translate(answered.description) ?? defaults.description,
@@ -37,12 +37,8 @@ export function createExportJobHandler(
37
37
  successMessage: target.labels.successMessage
38
38
  ? processI18n(target.labels.successMessage)
39
39
  : undefined,
40
- errorTitle: target.labels.errorTitle
41
- ? processI18n(target.labels.errorTitle)
42
- : undefined,
43
- retry: target.labels.retry
44
- ? processI18n(target.labels.retry)
45
- : undefined,
40
+ errorTitle: target.labels.errorTitle,
41
+ retry: target.labels.retry,
46
42
  }
47
43
  : undefined;
48
44
  const statusUrlTemplate = target.statusUrl;
@@ -121,19 +117,19 @@ export function createActionRequests(
121
117
  const handleApiTarget = async (
122
118
  target: ActionTarget & { type: "api" },
123
119
  url: string,
124
- label: string,
120
+ _label: string,
125
121
  rowData?: Data,
126
122
  selection?: BulkSelection,
127
123
  ) => {
128
124
  try {
129
125
  await requestApiTarget(target, url, rowData, {}, selection);
130
126
  } catch (error: unknown) {
131
- handleApiError(error, label ? processI18n(label, rowData) : undefined);
127
+ handleApiError(error);
132
128
  }
133
129
  };
134
130
  const handleDownloadTarget = async (
135
131
  target: ActionTarget & { type: "download" },
136
- label: string,
132
+ _label: string,
137
133
  rowData?: Data,
138
134
  ) => {
139
135
  try {
@@ -145,7 +141,7 @@ export function createActionRequests(
145
141
  fetchRaw,
146
142
  );
147
143
  } catch (error: unknown) {
148
- handleApiError(error, label ? processI18n(label, rowData) : undefined);
144
+ handleApiError(error);
149
145
  }
150
146
  };
151
147
  return { requestApiTarget, handleApiTarget, handleDownloadTarget };
@@ -13,7 +13,18 @@ function isGroupPoint(
13
13
  }
14
14
 
15
15
  function isSeries(entry: ChartSeries | ChartGroupPoint): entry is ChartSeries {
16
- return "data" in entry;
16
+ return "data" in entry && Array.isArray(entry.data);
17
+ }
18
+
19
+ /**
20
+ * The series of a fetched answer that carry their points. The answer comes
21
+ * from a route, so one answering another shape leaves its chart empty rather
22
+ * than failing the page the chart sits on.
23
+ */
24
+ export function drawableSeries(
25
+ series: ReadonlyArray<ChartSeries | ChartGroupPoint>,
26
+ ): ChartSeries[] {
27
+ return series.filter(isSeries);
17
28
  }
18
29
 
19
30
  /**
@@ -31,7 +42,7 @@ export function responseSeries(
31
42
  ): ChartSeries[] {
32
43
  const entries: Array<ChartSeries | ChartGroupPoint> = series;
33
44
  if (entries.length === 0 || !entries.every(isGroupPoint))
34
- return entries.filter(isSeries);
45
+ return drawableSeries(entries);
35
46
  const labelled = xaxisType === "category";
36
47
  const data = entries
37
48
  .filter(isGroupPoint)
@@ -1,150 +1,21 @@
1
- import {
2
- isObject as isObjectType,
3
- isString,
4
- } from "../../../../../dms-core/app/utils/type-check";
5
- import {
6
- readTranslationKey,
7
- type I18nTranslate,
8
- } from "#dms-core/app/composables/translation/useTranslation";
9
- import { describeFieldErrors } from "#dms-core/app/composables/useFieldErrors";
1
+ import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
10
2
 
11
- /** Shown when the request never reached the server (offline, DNS, CORS…). */
3
+ /** Legacy fallback key for requests that could not reach the server. */
12
4
  export const ACTION_ERROR_NETWORK_KEY = "error.network.description";
13
- /** Shown for a 401/403 whose body is not a user-facing message. */
5
+ /** Legacy fallback key for permission refusals. */
14
6
  export const ACTION_ERROR_FORBIDDEN_KEY = "dms.action_error.forbidden";
15
- /** Shown when the server's message is missing or not meant for users. */
7
+ /** Fallback key for actions without a more specific context. */
16
8
  export const ACTION_ERROR_GENERIC_KEY = "dms.action_error.generic";
17
9
 
18
- const HTTP_UNAUTHORIZED = 401;
19
- const HTTP_FORBIDDEN = 403;
20
- const HTTP_SERVER_ERROR = 500;
21
-
22
- const MAX_MESSAGE_LENGTH = 300;
23
-
24
- const KEY_PATTERN = /^\$?[\w-]+(\.[\w-]+)+$/;
25
-
26
- const TECHNICAL_PATTERNS = [
27
- /^\s*[[{<]/,
28
- /\n\s*at\s/,
29
- /^\s*[A-Z]\w*Error\b/,
30
- /<no response>/,
31
- ];
32
-
33
- const NETWORK_MESSAGES = [/failed to fetch/i, /networkerror/i, /load failed/i];
34
-
35
- interface FetchErrorLike {
36
- name?: string;
37
- message?: string;
38
- statusCode?: number;
39
- status?: number;
40
- response?: { status?: number };
41
- data?: unknown;
42
- }
43
-
44
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- Error bodies are untrusted; property values remain unknown until their individual checks.
45
- const isObject = (value: unknown): value is Record<string, unknown> =>
46
- isObjectType(value) && value !== null;
47
-
48
- const isFetchError = (error: unknown): error is FetchErrorLike =>
49
- isObject(error) &&
50
- (error.name === "FetchError" ||
51
- "statusCode" in error ||
52
- "response" in error ||
53
- "data" in error);
54
-
55
- const statusOf = (error: FetchErrorLike): number | undefined =>
56
- error.statusCode ?? error.status ?? error.response?.status;
57
-
58
- /** The message the server put in the error body, when it sent one. */
59
- const serverMessageOf = (error: FetchErrorLike): string | undefined => {
60
- const { data } = error;
61
- if (isString(data)) return data;
62
- // oxlint-disable-next-line anti-slop/no-known-value-widening -- The object guard accepts all untrusted error data; only an actual string message is returned.
63
- if (isObject(data) && isString(data.message)) return data.message;
64
- return undefined;
65
- };
66
-
67
- const isTechnical = (text: string) =>
68
- text.length > MAX_MESSAGE_LENGTH ||
69
- TECHNICAL_PATTERNS.some((pattern) => pattern.test(text));
70
-
71
- /**
72
- * `text` as the user should read it: a translated i18n key, or plain prose
73
- * as it came. Undefined when it is not user-facing (an untranslated key, JSON,
74
- * a stack trace…), for the caller to fall back on a generic message.
75
- */
76
- export const userFacingText = (
77
- text: string | undefined,
78
- translate: I18nTranslate,
79
- ): string | undefined => {
80
- const trimmed = text?.trim();
81
- if (!trimmed) return undefined;
82
- const { key } = readTranslationKey(trimmed);
83
- if (KEY_PATTERN.test(trimmed)) {
84
- const translated = translate(key, {});
85
- return translated !== key ? translated : undefined;
86
- }
87
- return isTechnical(trimmed) ? undefined : trimmed;
88
- };
89
-
90
- const translateKey = (translate: I18nTranslate, key: string) =>
91
- translate(key, {});
92
-
93
- /**
94
- * The message telling a user why an action failed, from whatever the request
95
- * threw: the server's own message when it is user-facing (an i18n key, or
96
- * plain text on a 4xx), else a translated fallback: the request never got
97
- * through, it was refused for lack of permission, or something went wrong.
98
- * Never a stack trace, raw JSON or an ofetch error line. A body the data API
99
- * refused for some fields names them by `labelOf` ("Loss reason is
100
- * required."): a table view's write passes its column labels.
101
- */
10
+ /** @deprecated Capture useApiErrorPresenter().resolveError during setup instead. */
102
11
  export function resolveActionError(
103
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- The request's catch value is decoded here and may be any thrown JavaScript value.
104
12
  error: unknown,
105
- translate: I18nTranslate,
106
- labelOf: (field: string) => string | undefined = () => undefined,
13
+ _translate: I18nTranslate,
14
+ _labelOf?: (field: string) => string | undefined,
107
15
  ): string {
108
- if (isFetchError(error)) {
109
- const status = statusOf(error);
110
- if (status === undefined) {
111
- return translateKey(translate, ACTION_ERROR_NETWORK_KEY);
112
- }
113
- const raw = serverMessageOf(error);
114
- const isForbidden =
115
- status === HTTP_UNAUTHORIZED || status === HTTP_FORBIDDEN;
116
-
117
- if (isForbidden || status >= HTTP_SERVER_ERROR) {
118
- const translated =
119
- raw && KEY_PATTERN.test(raw.trim())
120
- ? userFacingText(raw, translate)
121
- : undefined;
122
- return (
123
- translated ??
124
- translateKey(
125
- translate,
126
- isForbidden ? ACTION_ERROR_FORBIDDEN_KEY : ACTION_ERROR_GENERIC_KEY,
127
- )
128
- );
129
- }
130
- return (
131
- describeFieldErrors(error, translate, labelOf) ??
132
- userFacingText(raw, translate) ??
133
- translateKey(translate, ACTION_ERROR_GENERIC_KEY)
134
- );
135
- }
136
-
137
- const message =
138
- error instanceof Error
139
- ? error.message
140
- : isString(error)
141
- ? error
142
- : undefined;
143
- if (message && NETWORK_MESSAGES.some((pattern) => pattern.test(message))) {
144
- return translateKey(translate, ACTION_ERROR_NETWORK_KEY);
145
- }
146
16
  return (
147
- userFacingText(message, translate) ??
148
- translateKey(translate, ACTION_ERROR_GENERIC_KEY)
17
+ useApiErrorPresenter().resolveError(error, {
18
+ fallbackKey: ACTION_ERROR_GENERIC_KEY,
19
+ })?.message ?? ""
149
20
  );
150
21
  }
@@ -3,7 +3,6 @@ import type {
3
3
  ConfirmDialog,
4
4
  } from "#dms-core/app/types/confirm-dialog";
5
5
  import { isConfirmFrom } from "#dms-core/app/types/confirm-dialog";
6
- import { resolveActionError } from "./actionError";
7
6
  import type {
8
7
  ConfirmOptions,
9
8
  ConfirmPartialOutcome,
@@ -43,8 +42,7 @@ export function useActionConfirm() {
43
42
  const { confirm } = useConfirm();
44
43
  const { $authFetch } = useAuthFetch();
45
44
  const { t } = useI18n();
46
- const toast = useToast();
47
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
45
+ const { presentError } = useApiErrorPresenter();
48
46
  const runSecondaryTarget = secondaryTargetRunner($authFetch.raw);
49
47
 
50
48
  const loadDialog = async (
@@ -62,13 +60,7 @@ export function useActionConfirm() {
62
60
  try {
63
61
  return await $authFetch<ConfirmDialog>(url);
64
62
  } catch (error) {
65
- if (consumeReadOnlyRefusal(error)) return undefined;
66
- toast.add({
67
- color: Color.error,
68
- icon: "i-ph-warning-circle",
69
- title: t("dms.form.error_title"),
70
- description: resolveActionError(error, t),
71
- });
63
+ presentError(error, { fallbackKey: "dms.action_error.generic" });
72
64
  return undefined;
73
65
  }
74
66
  };
@@ -16,7 +16,6 @@ import {
16
16
  } from "../../utils/urlVariables";
17
17
  import { apiErrorStatus } from "#dms-core/app/composables/useFieldErrors";
18
18
  import { HTTP_NOT_FOUND } from "#dms-core/app/utils/http-status";
19
- import { isString } from "#dms-core/app/utils/type-check";
20
19
  import { sameFormValue } from "../unsaved-changes/formValue";
21
20
  import {
22
21
  type FieldValueComparer,
@@ -135,12 +134,11 @@ function createFormState(props: FormProps) {
135
134
  /** @internal */
136
135
  export function createFormFields(props: FormProps) {
137
136
  const context = createFormState(props);
137
+ const { presentError } = useApiErrorPresenter();
138
138
  const {
139
- toast,
140
139
  $authFetch,
141
140
  getDataType,
142
141
  processI18n,
143
- processApiMessage,
144
142
  route,
145
143
  initialValues,
146
144
  disabledFields,
@@ -205,15 +203,7 @@ export function createFormFields(props: FormProps) {
205
203
  };
206
204
 
207
205
  const showFetchErrorToast = (error: Parameters<typeof apiErrorStatus>[0]) => {
208
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Rejections are arbitrary; keep direct data access, primitive boxing and nullish throws rather than treating this unchecked error projection as validated.
209
- const err = error as Error & { data?: string };
210
- toast.add({
211
- title: processI18n("$dms.form.fetch_error_title"),
212
- description: isString(err.data)
213
- ? processApiMessage(err.data)
214
- : processI18n("$dms.form.fetch_error_unknown"),
215
- color: Color.error,
216
- });
206
+ presentError(error, { fallbackKey: "dms.form.fetch_error_unknown" });
217
207
  };
218
208
 
219
209
  const baseSchema = createBaseValidationSchema(props.schema);
@@ -51,6 +51,7 @@ export const useTableConfigClipboard = <T extends Data>() => {
51
51
  injectLocal<ShallowRef<TableSharedData<T>>>("tableSharedData");
52
52
  const toast = useToast();
53
53
  const { t } = useI18n();
54
+ const { presentError } = useApiErrorPresenter();
54
55
 
55
56
  const buildConfig = (): SerializedTableConfig | null => {
56
57
  const data = tableSharedData?.value;
@@ -97,11 +98,9 @@ export const useTableConfigClipboard = <T extends Data>() => {
97
98
  title: t("dms.table.export_config_copied"),
98
99
  duration: TOAST_DURATION_MS,
99
100
  });
100
- } catch {
101
- toast.add({
102
- color: "error",
103
- title: t("dms.table.export_config_failed"),
104
- description: code,
101
+ } catch (error) {
102
+ presentError(error, {
103
+ fallbackKey: "dms.table.export_config_failed",
105
104
  });
106
105
  }
107
106
  };
@@ -117,11 +116,9 @@ export const useTableConfigClipboard = <T extends Data>() => {
117
116
  duration: TOAST_DURATION_MS,
118
117
  });
119
118
  return true;
120
- } catch {
121
- toast.add({
122
- color: "error",
123
- title: t("dms.table.import_config_invalid"),
124
- duration: TOAST_DURATION_MS,
119
+ } catch (error) {
120
+ presentError(error, {
121
+ fallbackKey: "dms.table.import_config_invalid",
125
122
  });
126
123
  return false;
127
124
  }
@@ -17,9 +17,8 @@ import {
17
17
  type ConfirmPartialOutcome,
18
18
  } from "../../../composables/confirm/types";
19
19
  import { getErrorPayload } from "./tableRowActionValues";
20
- import type { describeTableWriteError } from "./utils/writeError";
21
20
  export type TableRowActionErrorHandler = (
22
- error: Parameters<typeof describeTableWriteError>[0],
21
+ error: unknown,
23
22
  title?: string,
24
23
  ) => void;
25
24
  const createBulkRequest = (config: TableRowActionsConfig) => {
@@ -92,7 +91,8 @@ export const createTableRowBulkOutcomes = (
92
91
  services: TableRowActionServices,
93
92
  handleApiError: TableRowActionErrorHandler,
94
93
  ) => {
95
- const { toast, t } = services;
94
+ const { t } = services;
95
+ const { presentError } = useApiErrorPresenter();
96
96
  const requestBulkAction = createBulkRequest(config);
97
97
  const reportBulkSuccess = createBulkReporter(config, services);
98
98
  /**
@@ -112,7 +112,10 @@ export const createTableRowBulkOutcomes = (
112
112
  const outcome = await requestBulkAction(ids, bulkConfig, values);
113
113
  const shortfall = bulkActionShortfall(bulkConfig.kind, outcome, t);
114
114
  if (outcome.processed === 0 && shortfall) {
115
- throw new ConfirmActionError(shortfall);
115
+ throw new ConfirmActionError({
116
+ fallbackKey: `dms.table.bulk_refused.${bulkConfig.kind}`,
117
+ params: { count: outcome.skipped },
118
+ });
116
119
  }
117
120
  reportBulkSuccess(outcome.processed, actionConfig, bulkConfig);
118
121
  if (shortfall) return { partial: shortfall };
@@ -131,16 +134,14 @@ export const createTableRowBulkOutcomes = (
131
134
  try {
132
135
  outcome = await requestBulkAction(ids, bulkConfig);
133
136
  } catch (error) {
134
- handleApiError(error, t(bulkConfig.errorKey));
137
+ handleApiError(error, bulkConfig.errorKey);
135
138
  return false;
136
139
  }
137
140
  const shortfall = bulkActionShortfall(bulkConfig.kind, outcome, t);
138
141
  if (outcome.processed === 0) {
139
- toast.add({
140
- color: Color.error,
141
- icon: "i-ph-warning-circle",
142
- title: shortfall?.title ?? t(bulkConfig.errorKey),
143
- description: shortfall?.description,
142
+ presentError(null, {
143
+ fallbackKey: `dms.table.bulk_refused.${bulkConfig.kind}`,
144
+ params: { count: outcome.skipped },
144
145
  });
145
146
  return false;
146
147
  }
@@ -1,5 +1,4 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
- import type { describeTableWriteError } from "./utils/writeError";
3
2
  export const DEFAULT_ROW_ID_KEY = "_id";
4
3
 
5
4
  export const getItemId = (
@@ -9,9 +8,7 @@ export const getItemId = (
9
8
  return get(item, idKey) ?? item._id ?? item.id;
10
9
  };
11
10
 
12
- export const getErrorPayload = (
13
- error: Parameters<typeof describeTableWriteError>[0],
14
- ) => {
11
+ export const getErrorPayload = (error: unknown) => {
15
12
  // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Rejections may be arbitrary JavaScript values, not EventError instances. Its owner describes only the fields forwarded here; inference cannot prove them, and validation would change primitive and malformed-error payloads.
16
13
  const err = error as EventError;
17
14
  return err?.data || err?.message;
@@ -3,7 +3,7 @@ import {
3
3
  CARDS_DISPLAY_ID,
4
4
  } from "../../../composables/table-view/types";
5
5
  import { KANBAN_DISPLAY_BRIDGE_KEY } from "../../../composables/table-view/kanban";
6
- import { describeTableWriteError } from "./utils/writeError";
6
+ import { tableWriteErrorOptions } from "./utils/writeError";
7
7
  import { buildTableQuery, isFilterEffective } from "./utils/tableQuery";
8
8
  import { useGroupedRows } from "./useGroupedRows";
9
9
  import { useTableReorder } from "./useTableReorder";
@@ -32,10 +32,9 @@ export function useTableViewReorder<T extends Data>(
32
32
  const { effectiveColumnFilters, effectiveGlobalFilter } = scope.querySorting;
33
33
  const { activeTabFilters, t } = scope.tabs;
34
34
  const { activeDisplayId } = scope.displayOptions;
35
- const { toast } = scope.sortRecovery;
36
35
  const { processI18n } = scope.arrival;
37
36
  const { props } = scope;
38
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
37
+ const { presentError } = useApiErrorPresenter();
39
38
  const ORDERED_DISPLAY_IDS = new Set([TABLE_DISPLAY_ID, CARDS_DISPLAY_ID]);
40
39
  const { reorderState } = useTableReorder<T>({
41
40
  reorder: reorderConfig,
@@ -55,18 +54,16 @@ export function useTableViewReorder<T extends Data>(
55
54
  ),
56
55
  refresh: () => refresh(),
57
56
  onError: (failure) => {
58
- if (consumeReadOnlyRefusal(failure)) return;
59
- toast.add({
60
- title: t("dms.table.reorder.error"),
61
- description: describeTableWriteError(
57
+ presentError(
58
+ failure,
59
+ tableWriteErrorOptions(
62
60
  failure,
63
61
  t,
64
62
  allColumns,
65
63
  processI18n,
64
+ "dms.table.reorder.error",
66
65
  ),
67
- color: Color.error,
68
- icon: "i-ph-warning-circle",
69
- });
66
+ );
70
67
  },
71
68
  });
72
69
  return { reorderState };
@@ -18,6 +18,7 @@ export function useExpandedRowDetails<T>(
18
18
  getRow: (id: string) => Promise<T | undefined>,
19
19
  ) {
20
20
  const entries = ref<Record<string, ExpandedRowDetailEntry<T>>>({});
21
+ const { presentError } = useApiErrorPresenter();
21
22
  let generation = 0;
22
23
 
23
24
  const setEntry = (id: string, entry: ExpandedRowDetailEntry<T>) => {
@@ -30,7 +31,9 @@ export function useExpandedRowDetails<T>(
30
31
  let row: T | undefined;
31
32
  try {
32
33
  row = await getRow(id);
33
- } catch {
34
+ } catch (error) {
35
+ if (loadedFor !== generation) return;
36
+ presentError(error, { fallbackKey: "dms.table.load_error_title" });
34
37
  row = undefined;
35
38
  }
36
39
  if (loadedFor !== generation) return;