@antelopejs/dms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/data/dbip-country-lite.mmdb +0 -0
  4. package/data/module-store/catalog.json +0 -6
  5. package/dist/config/internal/state.d.ts +5 -0
  6. package/dist/config/internal/state.js.map +1 -1
  7. package/dist/frontend-module.js +1 -0
  8. package/dist/frontend-module.js.map +1 -1
  9. package/dist/implementations/dms/page/quick-action-access.js +4 -1
  10. package/dist/implementations/dms/page/quick-action-access.js.map +1 -1
  11. package/dist/index.js +13 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/store/operations.js +3 -1
  14. package/dist/store/operations.js.map +1 -1
  15. package/dist/store/runtime.d.ts +1 -0
  16. package/dist/store/runtime.js +9 -0
  17. package/dist/store/runtime.js.map +1 -1
  18. package/dist/utils/ensure-default-tenant.js +7 -2
  19. package/dist/utils/ensure-default-tenant.js.map +1 -1
  20. package/frontend-vue/dms.frontend.ts +2 -0
  21. package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
  22. package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -11
  23. package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
  24. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
  25. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/login.vue +3 -1
  26. package/frontend-vue/layers/dms-auth/server/oauth.ts +5 -3
  27. package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
  28. package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
  29. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +2 -2
  30. package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
  31. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -10
  32. package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
  33. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -26
  34. package/frontend-vue/layers/dms-core/app/utils/composedText.ts +11 -4
  35. package/frontend-vue/layers/dms-core/app/utils/row-action-rule-evaluator.ts +8 -36
  36. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
  37. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
  38. package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +6 -0
  39. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidePanel.vue +3 -2
  40. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +14 -0
  41. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
  42. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
  43. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +6 -6
  44. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
  45. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
  46. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
  47. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
  48. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
  49. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
  50. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +1 -1
  51. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
  52. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
  53. package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
  54. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
  55. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
  56. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +2 -4
  57. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +2 -4
  58. package/frontend-vue/layers/dms-layout/server/render-token.ts +5 -3
  59. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
  60. package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
  61. package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +51 -0
  62. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
  63. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -8
  64. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/defaultPrimitiveTypes.ts +6 -8
  65. package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
  66. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +2 -3
  67. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
  68. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
  69. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
  70. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +9 -9
  71. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
  72. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +8 -3
  73. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -5
  74. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -16
  75. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
  76. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
  77. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
  78. package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
  79. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
  80. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +4 -1
  81. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
  82. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -16
  83. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
  84. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +2 -4
  85. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
  86. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
  87. package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
  88. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
  89. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
  90. package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
  91. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
  92. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -9
  93. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +11 -1
  94. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
  95. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -21
  96. package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
  97. package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
  98. package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
  99. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  100. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  101. package/frontend-vue/package.json +3 -5
  102. package/frontend-vue/scripts/check-api-error-presentation.mjs +242 -0
  103. package/frontend-vue/scripts/check-invariants.mjs +23 -0
  104. package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
  105. package/frontend-vue/scripts/test-api-error-presentation.mjs +93 -0
  106. package/package.json +23 -7
  107. package/skills/dms-module/REFERENCE.md +3 -1
  108. package/skills/dms-module/SKILL.md +7 -2
  109. package/skills/dms-project/SKILL.md +9 -3
@@ -0,0 +1,51 @@
1
+ import type {
2
+ ChartGroupPoint,
3
+ ChartResponse,
4
+ ChartSeries,
5
+ } from "../../../composables/chart/types";
6
+
7
+ type XAxisType = "category" | "datetime" | "numeric";
8
+
9
+ function isGroupPoint(
10
+ entry: ChartSeries | ChartGroupPoint,
11
+ ): entry is ChartGroupPoint {
12
+ return "x" in entry && "y" in entry && !("data" in entry);
13
+ }
14
+
15
+ function isSeries(entry: ChartSeries | ChartGroupPoint): entry is ChartSeries {
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);
28
+ }
29
+
30
+ /**
31
+ * The series a bare chart draws from its data source's answer.
32
+ *
33
+ * One point per group becomes one series called `name`. On a category axis
34
+ * each group key becomes its label: Apex lays numeric keys out on a number
35
+ * line whatever axis type it is given, which bunches `202512` and `202601`
36
+ * apart and shifts the bars of a numeric breakdown off their labels.
37
+ */
38
+ export function responseSeries(
39
+ series: ChartResponse["series"],
40
+ name: string,
41
+ xaxisType: XAxisType = "category",
42
+ ): ChartSeries[] {
43
+ const entries: Array<ChartSeries | ChartGroupPoint> = series;
44
+ if (entries.length === 0 || !entries.every(isGroupPoint))
45
+ return drawableSeries(entries);
46
+ const labelled = xaxisType === "category";
47
+ const data = entries
48
+ .filter(isGroupPoint)
49
+ .map((point) => (labelled ? { ...point, x: String(point.x) } : point));
50
+ return [{ name, data }];
51
+ }
@@ -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,7 +42,7 @@ export function useActionConfirm() {
43
42
  const { confirm } = useConfirm();
44
43
  const { $authFetch } = useAuthFetch();
45
44
  const { t } = useI18n();
46
- const toast = useToast();
45
+ const { presentError } = useApiErrorPresenter();
47
46
  const runSecondaryTarget = secondaryTargetRunner($authFetch.raw);
48
47
 
49
48
  const loadDialog = async (
@@ -61,12 +60,7 @@ export function useActionConfirm() {
61
60
  try {
62
61
  return await $authFetch<ConfirmDialog>(url);
63
62
  } catch (error) {
64
- toast.add({
65
- color: Color.error,
66
- icon: "i-ph-warning-circle",
67
- title: t("dms.form.error_title"),
68
- description: resolveActionError(error, t),
69
- });
63
+ presentError(error, { fallbackKey: "dms.action_error.generic" });
70
64
  return undefined;
71
65
  }
72
66
  };
@@ -37,16 +37,14 @@ function renderBoolean(value: CellValue) {
37
37
  ]);
38
38
  }
39
39
 
40
+ function dateBoundsOf(value: CellValue): [unknown, unknown] {
41
+ const bounds: { start?: unknown; end?: unknown } = value ?? {};
42
+ return [bounds.start, bounds.end];
43
+ }
44
+
40
45
  function formatDateBetween(value: CellValue, locale: string): string {
41
46
  const { t } = getAppTranslation();
42
- const [rawStart, rawEnd] = Array.isArray(value)
43
- ? value
44
- : [
45
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The open formatter value has no date-range schema; preserve optional start access on the original input.
46
- (value as { start?: unknown })?.start,
47
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The open formatter value has no date-range schema; preserve optional end access on the original input.
48
- (value as { end?: unknown })?.end,
49
- ];
47
+ const [rawStart, rawEnd] = Array.isArray(value) ? value : dateBoundsOf(value);
50
48
  const start = formatDate(rawStart, locale) ?? t("dms.date.undefined");
51
49
  const end = formatDate(rawEnd, locale) ?? t("dms.date.undefined");
52
50
  return `${start} - ${end}`;
@@ -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);
@@ -1,4 +1,4 @@
1
- import type { ComponentPublicInstance, VNode } from "vue";
1
+ import { isVNode, type ComponentPublicInstance, type VNode } from "vue";
2
2
 
3
3
  const blocks = new Map<string, ComponentPublicInstance>();
4
4
 
@@ -28,8 +28,7 @@ function firstElementOf(vnode: VNode | null | undefined): Element | null {
28
28
  if (vnode.component) return firstElementOf(vnode.component.subTree);
29
29
  if (!Array.isArray(vnode.children)) return null;
30
30
  for (const child of vnode.children) {
31
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- VNode child representations can include scalars and arrays; retain the unchecked recursive projection and null fallback.
32
- const element = firstElementOf(child as VNode);
31
+ const element = isVNode(child) ? firstElementOf(child) : null;
33
32
  if (element) return element;
34
33
  }
35
34
  return null;
@@ -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,9 +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;
37
+ const { presentError } = useApiErrorPresenter();
38
38
  const ORDERED_DISPLAY_IDS = new Set([TABLE_DISPLAY_ID, CARDS_DISPLAY_ID]);
39
39
  const { reorderState } = useTableReorder<T>({
40
40
  reorder: reorderConfig,
@@ -53,18 +53,18 @@ export function useTableViewReorder<T extends Data>(
53
53
  ORDERED_DISPLAY_IDS.has(activeDisplayId.value),
54
54
  ),
55
55
  refresh: () => refresh(),
56
- onError: (failure) =>
57
- toast.add({
58
- title: t("dms.table.reorder.error"),
59
- description: describeTableWriteError(
56
+ onError: (failure) => {
57
+ presentError(
58
+ failure,
59
+ tableWriteErrorOptions(
60
60
  failure,
61
61
  t,
62
62
  allColumns,
63
63
  processI18n,
64
+ "dms.table.reorder.error",
64
65
  ),
65
- color: Color.error,
66
- icon: "i-ph-warning-circle",
67
- }),
66
+ );
67
+ },
68
68
  });
69
69
  return { reorderState };
70
70
  }
@@ -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;
@@ -49,18 +49,23 @@ 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
 
55
56
  const settle = async (id: string) => {
56
57
  const found = await options.resolve(id);
57
58
  const outcome = deepLinkOutcome(found, action);
58
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- deepLinkOutcome returns a string rather than a row type predicate. DeepLinkedRow keeps row optional; the open outcome guarantees it operationally but neither inference nor its owner expresses that relation.
59
- if (outcome === "open") return options.open(found.row as T);
59
+ if (outcome === "open") {
60
+ if (found.row) options.open(found.row);
61
+ return;
62
+ }
60
63
  if (outcome === "missingPanel") return options.showMissing(id);
61
64
  writeRecordId(urlScope);
62
65
  if (linkedId === id) linkedId = undefined;
63
- options.notify(outcome);
66
+ if (outcome === "notifyFailed")
67
+ presentError(found.error, { fallbackKey: "dms.table.load_error_title" });
68
+ else options.notify(outcome);
64
69
  };
65
70
 
66
71
  const follow = (query: LocationQuery) => {
@@ -1,5 +1,6 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
2
  import { computed } from "vue";
3
+ import { stringifyQuery, withQuery } from "ufo";
3
4
  import { interpolateUrl } from "#dms-core/app/utils/url-interpolation";
4
5
  import { isString } from "#dms-core/app/utils/type-check";
5
6
  import type {
@@ -58,6 +59,48 @@ const DESCENDING = "desc";
58
59
  */
59
60
  export const LIVE_HELD_AT_QUERY_KEY = "liveHeldAt";
60
61
  const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
62
+ const MAX_BATCH_COUNT_QUERIES = 50;
63
+ const MAX_BATCH_COUNT_TARGET_BYTES = 8_192;
64
+
65
+ function encodeCountQuery({ id, query }: TableCountQuery): string {
66
+ const values = Object.fromEntries(
67
+ Object.entries(query)
68
+ .filter(([, value]) => value !== undefined)
69
+ .map(([key, value]) => [
70
+ key,
71
+ Array.isArray(value) ? value.map(String) : String(value),
72
+ ]),
73
+ );
74
+ return `${encodeURIComponent(id)}?${stringifyQuery(values)}`;
75
+ }
76
+
77
+ function countBatchTargetBytes(location: string, entries: string[]): number {
78
+ const url = new URL(location, "http://dms.invalid");
79
+ const target = withQuery(url.pathname + url.search, { query: entries });
80
+ return new TextEncoder().encode(target).byteLength;
81
+ }
82
+
83
+ function packCountQueries(
84
+ location: string,
85
+ queries: TableCountQuery[],
86
+ ): string[][] {
87
+ const batches: string[][] = [];
88
+ let batch: string[] = [];
89
+ for (const entry of queries.map(encodeCountQuery)) {
90
+ if (
91
+ batch.length > 0 &&
92
+ (batch.length >= MAX_BATCH_COUNT_QUERIES ||
93
+ countBatchTargetBytes(location, [...batch, entry]) >
94
+ MAX_BATCH_COUNT_TARGET_BYTES)
95
+ ) {
96
+ batches.push(batch);
97
+ batch = [];
98
+ }
99
+ batch.push(entry);
100
+ }
101
+ batches.push(batch);
102
+ return batches;
103
+ }
61
104
 
62
105
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
63
106
 
@@ -347,11 +390,16 @@ export function useTableRows<T extends Record<string, unknown>>(
347
390
  signal?: AbortSignal,
348
391
  ): Promise<Record<string, number>> => {
349
392
  if (!source) {
350
- return api<Record<string, number>>(`${location}/count/batch`, {
351
- method: "POST",
352
- body: { queries },
353
- signal,
354
- });
393
+ const endpoint = `${location}/count/batch`;
394
+ const counts = await Promise.all(
395
+ packCountQueries(endpoint, queries).map((entries) =>
396
+ api<Record<string, number>>(endpoint, {
397
+ query: { query: entries },
398
+ signal,
399
+ }),
400
+ ),
401
+ );
402
+ return Object.assign({}, ...counts);
355
403
  }
356
404
  const totals = await Promise.all(
357
405
  queries.map(async ({ id, query }) => {
@@ -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,6 +14,7 @@ export const useTableRowActions = <T extends Data>(
14
14
  config: TableRowActionsConfig,
15
15
  ) => {
16
16
  const toast = useToast();
17
+ const { presentError } = useApiErrorPresenter();
17
18
  const { t, locale } = useI18n();
18
19
  const { confirm } = useConfirm();
19
20
  const { confirmAction } = useActionConfirm();
@@ -38,21 +39,11 @@ export const useTableRowActions = <T extends Data>(
38
39
  * what failed, the description is the server's message when it is meant
39
40
  * for users, else a translated fallback (never a raw error line).
40
41
  */
41
- const handleApiError = (
42
- error: Parameters<typeof describeTableWriteError>[0],
43
- title?: string,
44
- ) => {
45
- toast.add({
46
- color: Color.error,
47
- icon: "i-ph-warning-circle",
48
- title: title || t("dms.form.error_title"),
49
- description: describeTableWriteError(
50
- error,
51
- t,
52
- config.columns,
53
- processI18n,
54
- ),
55
- });
42
+ const handleApiError = (error: unknown, title?: string) => {
43
+ presentError(
44
+ error,
45
+ tableWriteErrorOptions(error, t, config.columns, processI18n, title),
46
+ );
56
47
  };
57
48
 
58
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