@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
@@ -49,6 +49,7 @@ export function useTableRecordLink<T>(
49
49
  options: TableRecordLinkOptions<T>,
50
50
  ): TableRecordLink {
51
51
  const { action, urlScope } = options;
52
+ const { presentError } = useApiErrorPresenter();
52
53
 
53
54
  let linkedId: string | undefined;
54
55
 
@@ -62,7 +63,9 @@ export function useTableRecordLink<T>(
62
63
  if (outcome === "missingPanel") return options.showMissing(id);
63
64
  writeRecordId(urlScope);
64
65
  if (linkedId === id) linkedId = undefined;
65
- options.notify(outcome);
66
+ if (outcome === "notifyFailed")
67
+ presentError(found.error, { fallbackKey: "dms.table.load_error_title" });
68
+ else options.notify(outcome);
66
69
  };
67
70
 
68
71
  const follow = (query: LocationQuery) => {
@@ -1,6 +1,6 @@
1
1
  import { useActionConfirm } from "../confirm/useActionConfirm";
2
2
  import { useActionTargets } from "../actions/useActionTargets";
3
- import { describeTableWriteError } from "./utils/writeError";
3
+ import { tableWriteErrorOptions } from "./utils/writeError";
4
4
  import type {
5
5
  TableRowActionsConfig,
6
6
  TableRowActionServices,
@@ -14,7 +14,7 @@ export const useTableRowActions = <T extends Data>(
14
14
  config: TableRowActionsConfig,
15
15
  ) => {
16
16
  const toast = useToast();
17
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
17
+ const { presentError } = useApiErrorPresenter();
18
18
  const { t, locale } = useI18n();
19
19
  const { confirm } = useConfirm();
20
20
  const { confirmAction } = useActionConfirm();
@@ -39,22 +39,11 @@ export const useTableRowActions = <T extends Data>(
39
39
  * what failed, the description is the server's message when it is meant
40
40
  * for users, else a translated fallback (never a raw error line).
41
41
  */
42
- const handleApiError = (
43
- error: Parameters<typeof describeTableWriteError>[0],
44
- title?: string,
45
- ) => {
46
- if (consumeReadOnlyRefusal(error)) return;
47
- toast.add({
48
- color: Color.error,
49
- icon: "i-ph-warning-circle",
50
- title: title || t("dms.form.error_title"),
51
- description: describeTableWriteError(
52
- error,
53
- t,
54
- config.columns,
55
- processI18n,
56
- ),
57
- });
42
+ const handleApiError = (error: unknown, title?: string) => {
43
+ presentError(
44
+ error,
45
+ tableWriteErrorOptions(error, t, config.columns, processI18n, title),
46
+ );
58
47
  };
59
48
 
60
49
  const createFormContainer = createTableRowFormContainer<T>(config, services);
@@ -106,6 +106,8 @@ export interface DeepLinkedRow<T> {
106
106
  * error): the row may still exist, the link just could not be followed.
107
107
  */
108
108
  isFailed?: boolean;
109
+ /** Original request failure retained for presentation. */
110
+ error?: unknown;
109
111
  }
110
112
 
111
113
  /**
@@ -125,7 +127,7 @@ export async function resolveDeepLinkedRow<T>(
125
127
  return { row: row ?? undefined, isMissing: false };
126
128
  } catch (error: unknown) {
127
129
  if (readFetchNotFound(error) !== null) return { isMissing: true };
128
- return { isMissing: false, isFailed: true };
130
+ return { isMissing: false, isFailed: true, error };
129
131
  }
130
132
  }
131
133
 
@@ -1,6 +1,6 @@
1
1
  import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
2
2
  import type { TableViewColumn } from "../../../../composables/table-view/types/column";
3
- import { resolveActionError } from "../../confirm/actionError";
3
+ import { describeFieldErrors } from "#dms-core/app/composables/useFieldErrors";
4
4
 
5
5
  /**
6
6
  * Why a table view's write failed (a card moved on the board, a row moved
@@ -8,14 +8,18 @@ import { resolveActionError } from "../../confirm/actionError";
8
8
  * data API refused named by its column's label ("Loss reason is
9
9
  * required."). Never the raw ofetch error line.
10
10
  */
11
- export function describeTableWriteError(
12
- error: Parameters<typeof resolveActionError>[0],
11
+ export function tableWriteErrorOptions(
12
+ error: unknown,
13
13
  translate: I18nTranslate,
14
14
  columns: ReadonlyArray<Pick<TableViewColumn, "accessorKey" | "header">>,
15
15
  processI18n: (text: string) => string,
16
- ): string {
17
- return resolveActionError(error, translate, (field) => {
16
+ fallbackKey = "dms.action_error.generic",
17
+ ): ResolveApiErrorOptions {
18
+ const summary = describeFieldErrors(error, translate, (field) => {
18
19
  const column = columns.find((entry) => entry.accessorKey === field);
19
20
  return column ? processI18n(column.header) : undefined;
20
21
  });
22
+ return summary
23
+ ? { fallbackKey: "dms.action_error.fields", params: { summary } }
24
+ : { fallbackKey };
21
25
  }
@@ -5,7 +5,7 @@ import {
5
5
  import { HTTP_NOT_FOUND } from "../../../../dms-core/app/utils/http-status";
6
6
  import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
7
7
  import type { BlockAction } from "../components/blocks/BlockActions.vue";
8
- import { userFacingText } from "../composables/confirm/actionError";
8
+ import type { ApiErrorPresenter } from "#dms-core/app/composables/useApiErrorPresenter";
9
9
  import type { Tone } from "#dms-core/app/types/tone";
10
10
 
11
11
  /** Translation key of the not-found title, shared by blocks and guards. */
@@ -76,11 +76,12 @@ export function readFetchNotFound(
76
76
  export function notFoundTexts(
77
77
  notFound: FetchNotFound | null | undefined,
78
78
  translate: I18nTranslate,
79
+ resolveError: ApiErrorPresenter["resolveError"],
79
80
  ) {
80
81
  return {
81
82
  title: translate(NOT_FOUND_TITLE_KEY, {}),
82
83
  description:
83
- userFacingText(notFound?.message, translate) ??
84
- translate(NOT_FOUND_HINT_KEY, {}),
84
+ resolveError(notFound?.message, { fallbackKey: NOT_FOUND_HINT_KEY })
85
+ ?.message ?? translate(NOT_FOUND_HINT_KEY, {}),
85
86
  };
86
87
  }
@@ -14,6 +14,8 @@ import {
14
14
  } from "../../build/utils/blockPreview";
15
15
  import { childComponentId } from "../../utils/componentId";
16
16
 
17
+ const { resolveError } = useApiErrorPresenter();
18
+
17
19
  /**
18
20
  * Draws a tree of registered DMS blocks exactly as a page would.
19
21
  *
@@ -129,7 +131,10 @@ onMounted(async () => {
129
131
  fetchedBlockTypes.value =
130
132
  await $authFetch<PreviewBlockTypes>(BLOCK_TYPES_ENDPOINT);
131
133
  } catch (error: unknown) {
132
- embed?.reportError(error instanceof Error ? error.message : String(error));
134
+ const notice = resolveError(error, {
135
+ fallbackKey: "dms.blocks.load_error_hint",
136
+ });
137
+ if (notice) embed?.reportError(notice.message);
133
138
  }
134
139
  });
135
140
  </script>
@@ -92,12 +92,14 @@ const { data } = useChartFetch<BannerContent | null>({
92
92
  watchSource: () => JSON.stringify(watchState.value),
93
93
  });
94
94
 
95
+ const { presentError } = useApiErrorPresenter();
95
96
  const { handleCustomButton } = useActionTargets({
96
97
  api: useAuthFetch().$authFetch,
97
98
  componentId: props.componentId ?? FALLBACK_COMPONENT_ID,
98
99
  pageId: props.pageId ?? FALLBACK_PAGE_ID,
99
100
  refreshCallback: refreshPageBlocks,
100
- handleApiError: (error, title) => useApiError(error, { title }),
101
+ handleApiError: (error, title) =>
102
+ presentError(error, { fallbackKey: title ?? "error.500.description" }),
101
103
  });
102
104
 
103
105
  const hasFetchedContent = computed(
@@ -21,6 +21,7 @@ import {
21
21
  type ValuePrecision,
22
22
  } from "../../composables/chart/types";
23
23
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
24
+ import { drawableSeries } from "../../build/composables/chart/groupPoints";
24
25
 
25
26
  interface Props extends DefaultComponentProps {
26
27
  title: string;
@@ -93,13 +94,13 @@ function localized(entry: ChartSeries): ChartSeries {
93
94
  }
94
95
 
95
96
  const cardSeries = computed<ChartSeries[]>(() =>
96
- (data.value?.series ?? []).map(localized),
97
+ drawableSeries(data.value?.series ?? []).map(localized),
97
98
  );
98
99
 
99
100
  const cardComparisonSeries = computed<ChartSeries[]>(() =>
100
101
  comparison.isOff.value
101
102
  ? []
102
- : (data.value?.comparisonSeries ?? []).map((entry) => ({
103
+ : drawableSeries(data.value?.comparisonSeries ?? []).map((entry) => ({
103
104
  ...localized(entry),
104
105
  color: entry.color ?? comparisonColor.value,
105
106
  })),
@@ -23,7 +23,6 @@ import {
23
23
  ConfirmTextError,
24
24
  type ConfirmValues,
25
25
  } from "../../composables/confirm/types";
26
- import { resolveActionError } from "../../build/composables/confirm/actionError";
27
26
  import { isConfirmTextMatch } from "../../build/composables/confirm/confirmTextMatch";
28
27
  import type { ConfirmDialogField } from "#dms-core/app/types/confirm-dialog";
29
28
  import { resolveFieldErrors } from "#dms-core/app/composables/useFieldErrors";
@@ -113,7 +112,7 @@ const props = withDefaults(defineProps<ConfirmModalProps>(), {
113
112
  const emit = defineEmits<ConfirmModalEmits>();
114
113
  const slots = defineSlots<ConfirmModalSlots>();
115
114
  const { t } = useI18n();
116
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
115
+ const { resolveError } = useApiErrorPresenter();
117
116
 
118
117
  const BodyRender: FunctionalComponent = () => props.body?.();
119
118
 
@@ -124,8 +123,6 @@ const DEFAULT_ICONS: Partial<Record<ConfirmColor, string>> = {
124
123
  };
125
124
  const FALLBACK_ICON = "i-ph-question";
126
125
 
127
- const { processApiMessage } = useTranslation();
128
-
129
126
  const values = ref<ConfirmValues>(
130
127
  Object.fromEntries(
131
128
  props.fields.map((field) => [field.id, field.defaultValue]),
@@ -170,7 +167,7 @@ const typedText = ref("");
170
167
  const isPending = ref(false);
171
168
  const isSecondaryPending = ref(false);
172
169
 
173
- const notice = ref<ConfirmNotice & { tone: "error" | "warning" }>();
170
+ const notice = ref<ConfirmNotice & { tone: ApiErrorSeverity }>();
174
171
 
175
172
  const isSettled = ref(false);
176
173
 
@@ -246,13 +243,12 @@ const isPartialOutcome = (
246
243
  ): value is ConfirmPartialOutcome =>
247
244
  isObject(value) && value !== null && "partial" in value;
248
245
 
249
- function errorNotice(
250
- error: Parameters<typeof resolveActionError>[0],
251
- ): ConfirmNotice {
252
- if (error instanceof ConfirmActionError) {
253
- return { title: error.message, description: error.description };
254
- }
255
- return { title: resolveActionError(error, t) };
246
+ function resolveConfirmationFailure(error: unknown): ApiErrorNotice | null {
247
+ const action = error instanceof ConfirmActionError ? error : undefined;
248
+ return resolveError(error, {
249
+ fallbackKey: action?.message ?? "dms.action_error.generic",
250
+ params: action?.params,
251
+ });
256
252
  }
257
253
 
258
254
  /**
@@ -268,9 +264,12 @@ function showFieldErrors(
268
264
  });
269
265
  if (resolved.fields.length === 0) return false;
270
266
  fieldErrors.value = Object.fromEntries(
271
- resolved.fields.map(({ field, message }) => [
267
+ resolved.fields.map(({ field, message, params, fallbackKey }) => [
272
268
  field,
273
- processApiMessage(message),
269
+ resolveError(message, {
270
+ fallbackKey: fallbackKey ?? "dms.field_errors.invalid",
271
+ params,
272
+ })?.message,
274
273
  ]),
275
274
  );
276
275
  return !resolved.hasUnmatched;
@@ -293,11 +292,15 @@ async function runConfirm(
293
292
  close(true);
294
293
  }
295
294
  } catch (error) {
296
- if (consumeReadOnlyRefusal(error)) return;
297
295
  if (error instanceof ConfirmTextError && props.confirmText) {
298
- typedError.value = error.message;
296
+ typedError.value = resolveError(error, {
297
+ fallbackKey: "dms.field_errors.invalid",
298
+ })?.message;
299
299
  } else if (!showFieldErrors(error)) {
300
- notice.value = { ...errorNotice(error), tone: "error" };
300
+ const resolved = resolveConfirmationFailure(error);
301
+ notice.value = resolved
302
+ ? { title: resolved.message, tone: resolved.severity }
303
+ : undefined;
301
304
  }
302
305
  } finally {
303
306
  isPending.value = false;
@@ -343,8 +346,10 @@ async function handleSecondary() {
343
346
  try {
344
347
  await action.run();
345
348
  } catch (error) {
346
- if (consumeReadOnlyRefusal(error)) return;
347
- notice.value = { ...errorNotice(error), tone: "error" };
349
+ const resolved = resolveConfirmationFailure(error);
350
+ notice.value = resolved
351
+ ? { title: resolved.message, tone: resolved.severity }
352
+ : undefined;
348
353
  } finally {
349
354
  isSecondaryPending.value = false;
350
355
  }
@@ -60,7 +60,7 @@ const invalid = computed(() => fieldColor.value === "error");
60
60
 
61
61
  const instanceId = useId();
62
62
  const { $authFetch } = useAuthFetch();
63
- const toast = useToast();
63
+ const { presentError } = useApiErrorPresenter();
64
64
  const { t } = useI18n();
65
65
  const { processI18n } = useTranslation();
66
66
 
@@ -77,11 +77,7 @@ const { data, status, execute } = await useDmsAsyncData(
77
77
  return $authFetch<CascaderResponse>(url, {
78
78
  params: { limit: CASCADER_FETCH_LIMIT },
79
79
  onRequestError: ({ error }: { error: Error }) => {
80
- toast.add({
81
- color: Color.error,
82
- title: t("dms.form.error_title"),
83
- description: error.message,
84
- });
80
+ presentError(error, { fallbackKey: "dms.form.error_title" });
85
81
  },
86
82
  });
87
83
  },
@@ -41,6 +41,7 @@ const emit = defineEmits<{
41
41
 
42
42
  const { $authFetch } = useAuthFetch();
43
43
  const { t } = useI18n();
44
+ const { presentError, resolveError } = useApiErrorPresenter();
44
45
 
45
46
  const { emitFormChange, color, highlight, ariaAttrs } = useFormField();
46
47
 
@@ -156,11 +157,14 @@ const processFile = async (file: File) => {
156
157
  }
157
158
 
158
159
  emitValue();
159
- } catch (_error) {
160
+ } catch (error) {
160
161
  const uploadingFile = uploadingFiles.value.find((u) => u.id === uploadId);
161
162
  if (uploadingFile) {
162
- uploadingFile.error = "Upload failed";
163
+ uploadingFile.error = resolveError(error, {
164
+ fallbackKey: "dms.form.image.upload_failed",
165
+ })?.message;
163
166
  }
167
+ presentError(error, { fallbackKey: "dms.form.image.upload_failed" });
164
168
  } finally {
165
169
  setFieldLoading?.(uploadId, false);
166
170
  }
@@ -39,6 +39,7 @@ interface ImageProps {
39
39
  }
40
40
 
41
41
  const props = defineProps<ImageProps>();
42
+ const { resolveError } = useApiErrorPresenter();
42
43
  const emit = defineEmits<{
43
44
  "update:modelValue": [ImageItemValue | ImageItemValue[] | null];
44
45
  }>();
@@ -206,8 +207,11 @@ const runUpload = async (item: GalleryItem) => {
206
207
  item.progress = 100;
207
208
  ensurePrincipal();
208
209
  emitValue();
209
- } catch (_error) {
210
+ } catch (error) {
210
211
  item.status = "error";
212
+ item.errorMessage = resolveError(error, {
213
+ fallbackKey: "dms.form.image.upload_failed",
214
+ })?.message;
211
215
  } finally {
212
216
  setFieldLoading?.(item.id, false);
213
217
  }
@@ -168,7 +168,7 @@ const {
168
168
  <span
169
169
  class="bg-error rounded-md px-2 py-0.5 text-[11px] font-semibold text-white"
170
170
  >
171
- {{ t(failureLabelKey(item)!) }}
171
+ {{ item.errorMessage ?? t(failureLabelKey(item)!) }}
172
172
  </span>
173
173
  <UButton
174
174
  v-if="item.file"
@@ -311,7 +311,7 @@ const {
311
311
  <span
312
312
  class="bg-error rounded-md px-2 py-0.5 text-[11px] font-semibold text-white"
313
313
  >
314
- {{ t(failureLabelKey(singleItem)!) }}
314
+ {{ singleItem.errorMessage ?? t(failureLabelKey(singleItem)!) }}
315
315
  </span>
316
316
  <UButton
317
317
  v-if="singleItem.file"
@@ -223,7 +223,7 @@ const mapCountryData = (item: Record<string, string>) => {
223
223
  };
224
224
 
225
225
  const { $authFetch } = useAuthFetch();
226
- const toast = useToast();
226
+ const { presentError } = useApiErrorPresenter();
227
227
 
228
228
  /** The remote country list, already shaped for `USelectMenu`. */
229
229
  interface CountryOptions {
@@ -239,11 +239,7 @@ async function fetchCountryOptions(): Promise<CountryOptions> {
239
239
  q: countrySearchTermDebounced.value,
240
240
  },
241
241
  onRequestError: ({ error }: { error: Error }) => {
242
- toast.add({
243
- color: Color.error,
244
- title: t("dms.form.error_title"),
245
- description: error.message,
246
- });
242
+ presentError(error, { fallbackKey: "dms.form.error_title" });
247
243
  },
248
244
  });
249
245
 
@@ -130,7 +130,7 @@ const loadedFilterSource = filterSourceValue.value;
130
130
 
131
131
  const { $authFetch } = useAuthFetch();
132
132
  const { readRelationRows } = useRelationRows();
133
- const toast = useToast();
133
+ const { presentError } = useApiErrorPresenter();
134
134
  const { t } = useI18n();
135
135
  const { processI18n } = useTranslation();
136
136
  const { hasPermission, loadPermissions } = usePermissions();
@@ -229,11 +229,7 @@ async function fetchRelationItems(): Promise<RelationItems> {
229
229
  ...filterParams.value,
230
230
  },
231
231
  onRequestError: ({ error }: { error: Error }) => {
232
- toast.add({
233
- color: Color.error,
234
- title: t("dms.form.error_title"),
235
- description: error.message,
236
- });
232
+ presentError(error, { fallbackKey: "dms.form.error_title" });
237
233
  },
238
234
  });
239
235
 
@@ -16,6 +16,7 @@ export interface GalleryItem {
16
16
  principal: boolean;
17
17
  status: ItemStatus;
18
18
  progress: number;
19
+ errorMessage?: string;
19
20
  }
20
21
 
21
22
  const FAILURE_LABEL_KEYS: Partial<Record<ItemStatus, string>> = {
@@ -32,6 +32,7 @@ const props = withDefaults(defineProps<GuardProps>(), {
32
32
  });
33
33
 
34
34
  const { t } = useI18n();
35
+ const { resolveError } = useApiErrorPresenter();
35
36
 
36
37
  const state = useGuard(
37
38
  () => props,
@@ -44,7 +45,7 @@ const DEFAULT_FALLBACKS: Record<
44
45
  > = {
45
46
  empty: () => ({ title: t("dms.blocks.empty") }),
46
47
  notFound: ({ notFound }) => ({
47
- ...notFoundTexts(notFound, t),
48
+ ...notFoundTexts(notFound, t, resolveError),
48
49
  variant: "no-result",
49
50
  }),
50
51
  };
@@ -23,7 +23,7 @@ import {
23
23
  import { readSubline } from "../../build/composables/data-types/cellHelpers";
24
24
  import { useComposedText } from "#dms-core/app/composables/translation/useComposedText";
25
25
  import { useCardFields } from "../../build/composables/table-view/useCardFields";
26
- import { describeTableWriteError } from "../../build/composables/table-view/utils/writeError";
26
+ import { tableWriteErrorOptions } from "../../build/composables/table-view/utils/writeError";
27
27
 
28
28
  const DEFAULT_COLUMN_PAGE_SIZE = 10;
29
29
  const DEFAULT_COLUMN_MAX_HEIGHT = "60vh";
@@ -89,7 +89,7 @@ const { getDataType } = useDataTypes();
89
89
  const { t } = useI18n();
90
90
  const { processText } = useComposedText();
91
91
  const toast = useToast();
92
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
92
+ const { presentError } = useApiErrorPresenter();
93
93
 
94
94
  const rowIdKey = computed(() => props.rowIdKey ?? DEFAULT_ROW_ID_KEY);
95
95
  const pageSize = computed(
@@ -296,15 +296,17 @@ const awaitNewerMoves = async (itemId: string, current: Promise<void>) => {
296
296
  }
297
297
  };
298
298
 
299
- const notifyMoveError = (
300
- error: Parameters<typeof describeTableWriteError>[0],
301
- ) => {
302
- if (consumeReadOnlyRefusal(error)) return;
303
- toast.add({
304
- color: Color.error,
305
- title: t("dms.table.kanban.move_error"),
306
- description: describeTableWriteError(error, t, props.columns, processI18n),
307
- });
299
+ const notifyMoveError = (error: unknown) => {
300
+ presentError(
301
+ error,
302
+ tableWriteErrorOptions(
303
+ error,
304
+ t,
305
+ props.columns,
306
+ processI18n,
307
+ "dms.table.kanban.move_error",
308
+ ),
309
+ );
308
310
  };
309
311
 
310
312
  const onColumnChange = async (
@@ -115,15 +115,15 @@ export interface ConfirmPartialOutcome {
115
115
  }
116
116
 
117
117
  /**
118
- * Thrown by `onConfirm` with a message already worded for users (a title and
119
- * its reason): the modal shows both in its error alert, as they are.
118
+ * Thrown by `onConfirm` with a fallback translation key and optional params.
119
+ * A translated server reason retained in `cause` takes precedence.
120
120
  */
121
121
  export class ConfirmActionError extends Error {
122
- readonly description?: string;
122
+ readonly params?: ApiErrorParams;
123
123
 
124
- constructor(notice: ConfirmNotice) {
125
- super(notice.title);
126
- this.description = notice.description;
124
+ constructor(options: ResolveApiErrorOptions, cause?: ErrorOptions) {
125
+ super(options.fallbackKey, cause);
126
+ this.params = options.params;
127
127
  }
128
128
  }
129
129
 
@@ -9,7 +9,6 @@ import {
9
9
  type ReplaceUrlVariablesContext,
10
10
  resolveLinkVariables,
11
11
  } from "../../build/utils/urlVariables";
12
- import { apiErrorText } from "#dms-core/app/composables/useFieldErrors";
13
12
  import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
14
13
  import { isNumber } from "#dms-core/app/utils/type-check";
15
14
  import { isPlainObject } from "../../build/composables/unsaved-changes/formValue";
@@ -93,7 +92,7 @@ function createFormNotices(
93
92
  context: ReturnType<typeof createFormFields>,
94
93
  options: UseFormOptions,
95
94
  ) {
96
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
95
+ const { presentError, resolveError } = useApiErrorPresenter();
97
96
  const {
98
97
  props,
99
98
  toast,
@@ -144,13 +143,6 @@ function createFormNotices(
144
143
  });
145
144
  };
146
145
 
147
- const resolveSubmitErrorDescription = (error: EventError) => {
148
- if (props.errorMessage) return processI18n(props.errorMessage);
149
- const text = apiErrorText(error);
150
- if (text) return processApiMessage(text);
151
- return processI18n("$dms.form.error_unknown");
152
- };
153
-
154
146
  /** Puts a refused submit's field errors under its fields, if any. */
155
147
  const showServerFieldErrors = (
156
148
  error: Parameters<typeof resolveFormFieldErrors>[0],
@@ -166,10 +158,16 @@ function createFormNotices(
166
158
  name: entry.field,
167
159
  message: entry.values?.length
168
160
  ? t("dms.field_errors.with_values", {
169
- message: processApiMessage(entry.message, entry.params),
161
+ message: resolveError(entry.message, {
162
+ fallbackKey: entry.fallbackKey ?? "dms.field_errors.invalid",
163
+ params: entry.params,
164
+ })?.message,
170
165
  values: entry.values.join(", "),
171
166
  })
172
- : processApiMessage(entry.message, entry.params),
167
+ : (resolveError(entry.message, {
168
+ fallbackKey: entry.fallbackKey ?? "dms.field_errors.invalid",
169
+ params: entry.params,
170
+ })?.message ?? ""),
173
171
  values: entry.values,
174
172
  path: entry.path,
175
173
  })),
@@ -177,18 +175,15 @@ function createFormNotices(
177
175
  };
178
176
 
179
177
  const showSubmitErrorToast = (error: EventError) => {
180
- if (consumeReadOnlyRefusal(error)) return;
181
- toast.add({
182
- title: processI18n("$dms.form.error_title"),
183
- description: resolveSubmitErrorDescription(error),
184
- color: Color.error,
178
+ presentError(error, {
179
+ fallbackKey: props.errorMessage || "dms.form.error_unknown",
185
180
  });
186
181
  };
187
182
 
188
183
  return {
189
184
  ...context,
190
185
  options,
191
- consumeReadOnlyRefusal,
186
+ presentError,
192
187
  showSubmitSuccessToast,
193
188
  showSubmitNotice,
194
189
  showServerFieldErrors,
@@ -281,8 +276,7 @@ function createSavedForm(context: ReturnType<typeof createFormNotices>) {
281
276
  function createFormSubmission(context: ReturnType<typeof createSavedForm>) {
282
277
  const {
283
278
  props,
284
- toast,
285
- processI18n,
279
+ presentError,
286
280
  buildUrlContext,
287
281
  showSubmitSuccessToast,
288
282
  showSubmitNotice,
@@ -310,11 +304,7 @@ function createFormSubmission(context: ReturnType<typeof createSavedForm>) {
310
304
  };
311
305
 
312
306
  const showUnresolvedTargetToast = () => {
313
- toast.add({
314
- title: processI18n("$dms.form.error_title"),
315
- description: processI18n("$dms.form.error_no_target"),
316
- color: Color.error,
317
- });
307
+ presentError(null, { fallbackKey: "dms.form.error_no_target" });
318
308
  };
319
309
 
320
310
  /** Sends through submit events, marking accepted data clean before success. */
@@ -352,7 +342,6 @@ function createFormSubmission(context: ReturnType<typeof createSavedForm>) {
352
342
  const reportSubmitError = (
353
343
  error: Parameters<typeof resolveFormFieldErrors>[0],
354
344
  ): void => {
355
- if (context.consumeReadOnlyRefusal(error)) return;
356
345
  if (!showServerFieldErrors(error)) {
357
346
  // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Rejection values are passed unchanged to the existing error formatter; there is no Error-instance validation at this boundary.
358
347
  showSubmitErrorToast(error as EventError);