@truedat/core 8.13.5 → 8.13.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/core",
3
- "version": "8.13.5",
3
+ "version": "8.13.7",
4
4
  "description": "Truedat Web Core",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -57,7 +57,7 @@
57
57
  "@testing-library/jest-dom": "^6.6.3",
58
58
  "@testing-library/react": "^16.3.0",
59
59
  "@testing-library/user-event": "^14.6.1",
60
- "@truedat/test": "8.13.5",
60
+ "@truedat/test": "8.13.7",
61
61
  "identity-obj-proxy": "^3.0.0",
62
62
  "jest": "^29.7.0",
63
63
  "redux-saga-test-plan": "^4.0.6"
@@ -99,5 +99,5 @@
99
99
  "swr": "^2.3.3",
100
100
  "turndown": "^7.2.2"
101
101
  },
102
- "gitHead": "9b87cbb7143f20b305b84077c1fcf004e5b27726"
102
+ "gitHead": "704dcfcba34b6094a368a93059de7f4937502f32"
103
103
  }
@@ -11,10 +11,14 @@ import { AlertExporter } from "./AlertExporter";
11
11
  const existingList = (list) => list && !_.isEmpty(list);
12
12
 
13
13
  const safeFormat = (formatMessage) => (descriptor, values) => {
14
+ const fallback = descriptor.defaultMessage ?? descriptor.id;
15
+
14
16
  try {
15
- return formatMessage(descriptor, values);
17
+ const formatted = formatMessage(descriptor, values);
18
+
19
+ return formatted === descriptor.id ? fallback : formatted;
16
20
  } catch {
17
- return descriptor.defaultMessage ?? descriptor.id;
21
+ return fallback;
18
22
  }
19
23
  };
20
24
 
@@ -34,14 +38,14 @@ const formatErrorMessages = (error, formatMessage) => {
34
38
  });
35
39
  }
36
40
 
37
- if (_.every(["name", "code"], _.partial(_.has)(error)))
38
- return formatMessage(
39
- { id: error.name, defaultMessage: error.name },
40
- { text: error.code },
41
+ if (_.has("name")(error)) {
42
+ const message = safe(
43
+ { id: error.name },
44
+ { text: error.code, field: error.field, ...(error.values || {}) },
41
45
  );
42
46
 
43
- if (_.has("name")(error))
44
- return formatMessage({ id: error.name, defaultMessage: error.name });
47
+ return error.field ? `${error.field}: ${message}` : message;
48
+ }
45
49
 
46
50
  return formatMessage({
47
51
  id: "error.unknown",
@@ -2,7 +2,7 @@ import _ from "lodash/fp";
2
2
  import PropTypes from "prop-types";
3
3
 
4
4
  import { useEffect, useRef } from "react";
5
- import { Form, Label } from "semantic-ui-react";
5
+ import { Button, Form, Label, Message } from "semantic-ui-react";
6
6
  import { useIntl, FormattedMessage } from "react-intl";
7
7
  import { useQuery } from "@apollo/client";
8
8
  import { accentInsensitivePathOrder } from "../services/sort";
@@ -112,13 +112,37 @@ TemplateSelector.propTypes = {
112
112
  };
113
113
 
114
114
  const emptyTemplates = [];
115
+
116
+ export const TemplateLoadError = ({ onRetry }) => (
117
+ <Message negative compact>
118
+ <Message.Content>
119
+ <FormattedMessage
120
+ id="template.selector.load_error"
121
+ defaultMessage="Templates could not be loaded. Check your access and try again."
122
+ />
123
+ {onRetry ? (
124
+ <Button size="small" onClick={onRetry}>
125
+ <FormattedMessage
126
+ id="template.selector.retry"
127
+ defaultMessage="Retry"
128
+ />
129
+ </Button>
130
+ ) : null}
131
+ </Message.Content>
132
+ </Message>
133
+ );
134
+
135
+ TemplateLoadError.propTypes = {
136
+ onRetry: PropTypes.func,
137
+ };
138
+
115
139
  export const TemplateSelectorLoader = ({
116
140
  scope,
117
141
  onLoad,
118
142
  domainIds,
119
143
  ...props
120
144
  }) => {
121
- const { loading, error, data } = useQuery(TEMPLATES_QUERY, {
145
+ const { loading, error, data, refetch } = useQuery(TEMPLATES_QUERY, {
122
146
  fetchPolicy: "cache-and-network",
123
147
  variables: { scope, domainIds },
124
148
  });
@@ -131,7 +155,9 @@ export const TemplateSelectorLoader = ({
131
155
  }
132
156
  }, [data, onLoad]);
133
157
 
134
- if (error) return null;
158
+ if (error && !loading) {
159
+ return <TemplateLoadError onRetry={() => refetch()} />;
160
+ }
135
161
 
136
162
  const templates = data?.templates || emptyTemplates;
137
163
  return (
@@ -47,7 +47,13 @@ export const ResponseCell = ({
47
47
  );
48
48
  }
49
49
  case "ERROR":
50
- return <ErrorDetail response={response} showOrigin={showOrigin} />;
50
+ return (
51
+ <ErrorDetail
52
+ response={response}
53
+ showOrigin={showOrigin}
54
+ scope={scope}
55
+ />
56
+ );
51
57
  case "COMPLETED":
52
58
  return <CompletedDetail response={response} />;
53
59
  case "INFO":
@@ -209,7 +215,7 @@ const externalIdDetailValue = (details) =>
209
215
  (details?.external_id ?? "")
210
216
  );
211
217
 
212
- const ErrorDetail = ({ response, showOrigin }) => {
218
+ const ErrorDetail = ({ response, showOrigin, scope }) => {
213
219
  const { formatMessage } = useIntl();
214
220
 
215
221
  if (!response || !response.type) {
@@ -218,6 +224,31 @@ const ErrorDetail = ({ response, showOrigin }) => {
218
224
  const { type } = response;
219
225
  const messagePrefix = "uploadJob.parser.detail";
220
226
 
227
+ const translatedFieldError = (error, details) => {
228
+ const rawMessageId = error?.name || error?.message;
229
+ const resourcePrefix =
230
+ scope === "concepts"
231
+ ? "concept.error.content."
232
+ : details?.implementation_key
233
+ ? "implementation.error."
234
+ : details?.rule_name
235
+ ? "rule.error."
236
+ : details?.external_id
237
+ ? "structureNote.error."
238
+ : "";
239
+ const messageId = error?.name
240
+ ? rawMessageId.startsWith(resourcePrefix) || !resourcePrefix
241
+ ? rawMessageId
242
+ : `${resourcePrefix}${rawMessageId}`
243
+ : rawMessageId;
244
+ if (!messageId) return "";
245
+
246
+ return formatMessage(
247
+ { id: messageId, defaultMessage: error?.message || messageId },
248
+ { field: error?.field, ...(error?.values || {}) },
249
+ );
250
+ };
251
+
221
252
  if (!showOrigin && type === "data_structure_not_found") {
222
253
  return (
223
254
  <span className="upload-job-simple-error">
@@ -251,11 +282,11 @@ const ErrorDetail = ({ response, showOrigin }) => {
251
282
  }
252
283
  }
253
284
 
254
- const validationErrorPairs = (errors) => {
285
+ const validationErrorPairs = (errors, details) => {
255
286
  if (_.isArray(errors)) {
256
287
  return errors.map((e) => [
257
288
  e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
258
- e?.message ?? "",
289
+ translatedFieldError(e, details),
259
290
  ]);
260
291
  }
261
292
 
@@ -343,7 +374,7 @@ const ErrorDetail = ({ response, showOrigin }) => {
343
374
  [null, externalIdDetailValue(details)],
344
375
  ].filter(([, v]) => v !== undefined && v !== ""),
345
376
  field_validation_error: (details) =>
346
- validationErrorPairs(details?.errors) ?? [
377
+ validationErrorPairs(details?.errors, details) ?? [
347
378
  [null, externalIdDetailValue(details)],
348
379
  ],
349
380
  unprocessable_entity: (details) => [[null, externalIdDetailValue(details)]],
@@ -355,7 +386,7 @@ const ErrorDetail = ({ response, showOrigin }) => {
355
386
  ? `structures.uploadStructures.failed.note.${reason}`
356
387
  : null;
357
388
 
358
- const validationPairs = validationErrorPairs(details?.errors);
389
+ const validationPairs = validationErrorPairs(details?.errors, details);
359
390
 
360
391
  const errorPairs = _.isEmpty(validationPairs)
361
392
  ? [
@@ -105,6 +105,7 @@ export default function UploadJobs({ scope }) {
105
105
  <ResponseCell
106
106
  response={job.latest_event_response}
107
107
  status={job.latest_status}
108
+ scope={scope}
108
109
  />
109
110
  </Table.Cell>
110
111
  <Table.Cell>
@@ -0,0 +1,87 @@
1
+ import { renderWithIntl as render } from "@truedat/test/renderWithIntl";
2
+ import { waitForLoad } from "@truedat/test/waitForLoad";
3
+ import { Alert } from "../Alert";
4
+
5
+ const mockFormatMessage = jest.fn(({ id }) => id);
6
+
7
+ jest.mock("react-intl", () => ({
8
+ ...jest.requireActual("react-intl"),
9
+ useIntl: () => ({ formatMessage: mockFormatMessage }),
10
+ }));
11
+
12
+ beforeEach(() => mockFormatMessage.mockClear());
13
+
14
+ const renderError = async (error) => {
15
+ const rendered = render(
16
+ <Alert
17
+ dismissAlert={jest.fn()}
18
+ message={{
19
+ error: true,
20
+ header: "header.id",
21
+ icon: "attention",
22
+ text: "text",
23
+ errorList: [error],
24
+ }}
25
+ />,
26
+ );
27
+ await waitForLoad(rendered);
28
+ return rendered;
29
+ };
30
+
31
+ describe("a validation error with values", () => {
32
+ it("shows the message and the field it came from", async () => {
33
+ const rendered = await renderError({
34
+ code: "undefined",
35
+ name: "concept.error.content.errors.date_restrictions.out_of_range",
36
+ field: "fecha max fija",
37
+ values: { limit: "2026-06-30" },
38
+ });
39
+
40
+ const text = rendered.container.textContent;
41
+
42
+ expect(text).toContain("fecha max fija");
43
+ expect(text).toContain(
44
+ "concept.error.content.errors.date_restrictions.out_of_range",
45
+ );
46
+ });
47
+
48
+ it("passes the field and limit as interpolation parameters", async () => {
49
+ await renderError({
50
+ code: "undefined",
51
+ name: "concept.error.content.errors.date_restrictions.out_of_range",
52
+ field: "fecha max fija",
53
+ values: { limit: "2026-06-30" },
54
+ });
55
+
56
+ expect(mockFormatMessage).toHaveBeenCalledWith(
57
+ { id: "concept.error.content.errors.date_restrictions.out_of_range" },
58
+ {
59
+ text: "undefined",
60
+ field: "fecha max fija",
61
+ limit: "2026-06-30",
62
+ },
63
+ );
64
+ });
65
+
66
+ it("falls back to the key when there are no values", async () => {
67
+ const rendered = await renderError({
68
+ code: "undefined",
69
+ name: "concept.error.content.name.is.invalid",
70
+ });
71
+
72
+ expect(
73
+ rendered.getByText("concept.error.content.name.is.invalid"),
74
+ ).toBeInTheDocument();
75
+ });
76
+
77
+ it("keeps showing a plain error with a code and no field", async () => {
78
+ const rendered = await renderError({
79
+ code: "some_code",
80
+ name: "createDomain.error.name.unique",
81
+ });
82
+
83
+ expect(
84
+ rendered.getByText("createDomain.error.name.unique"),
85
+ ).toBeInTheDocument();
86
+ });
87
+ });
@@ -62,14 +62,31 @@ describe("<TemplateSelector />", () => {
62
62
  expect(rendered.container).toMatchSnapshot();
63
63
  });
64
64
 
65
- it("is empty if templates query returns an error", async () => {
65
+ it("shows an actionable message if templates query returns an error", async () => {
66
66
  const renderOpts = { mocks: [errorTemplateMock(variables)] };
67
67
  const rendered = render(
68
68
  <TemplateSelector {...props} required />,
69
69
  renderOpts,
70
70
  );
71
71
  await waitForLoad(rendered);
72
- expect(rendered.queryByText(/template1/i)).not.toBeInTheDocument();
72
+ expect(
73
+ rendered.getByText(/templates could not be loaded/i),
74
+ ).toBeInTheDocument();
75
+ expect(
76
+ rendered.getByRole("button", { name: /retry/i }),
77
+ ).toBeInTheDocument();
78
+ });
79
+
80
+ it("retries a failed templates query", async () => {
81
+ const renderOpts = {
82
+ mocks: [errorTemplateMock(variables), multipleTemplatesMock(variables)],
83
+ };
84
+ const rendered = render(<TemplateSelector {...props} />, renderOpts);
85
+ const user = userEvent.setup({ delay: null });
86
+
87
+ await user.click(await rendered.findByRole("button", { name: /retry/i }));
88
+
89
+ expect(await rendered.findByText(/template1/i)).toBeInTheDocument();
73
90
  });
74
91
 
75
92
  it("calls onChange when template is selected", async () => {
@@ -610,6 +610,91 @@ describe("<ResponseCell />", () => {
610
610
  expect(rendered.getByText(/Already exists/i)).toBeInTheDocument();
611
611
  });
612
612
 
613
+ it("translates named field errors and interpolates their field and values", () => {
614
+ const rendered = render(
615
+ <ResponseCell
616
+ response={{
617
+ type: "field_validation_error",
618
+ details: {
619
+ implementation_key: "implementation-key",
620
+ errors: [
621
+ {
622
+ name: "errors.date_restrictions.out_of_range",
623
+ message: "Invalid value",
624
+ field: "threshold",
625
+ values: { min: 10, max: 20 },
626
+ },
627
+ ],
628
+ },
629
+ }}
630
+ status="ERROR"
631
+ scope="concepts"
632
+ />,
633
+ {
634
+ messages: {
635
+ en: {
636
+ "concept.error.content.errors.date_restrictions.out_of_range":
637
+ "{field} must be between {min} and {max}",
638
+ },
639
+ },
640
+ },
641
+ );
642
+
643
+ expect(
644
+ rendered.getByText("threshold must be between 10 and 20"),
645
+ ).toBeInTheDocument();
646
+ });
647
+
648
+ it("keeps field errors with only a message compatible", () => {
649
+ const rendered = render(
650
+ <ResponseCell
651
+ response={{
652
+ type: "field_validation_error",
653
+ details: {
654
+ errors: [{ field: "name", message: "Required" }],
655
+ },
656
+ }}
657
+ status="ERROR"
658
+ />,
659
+ );
660
+
661
+ expect(rendered.getByText("Required")).toBeInTheDocument();
662
+ });
663
+
664
+ it("translates named implementation errors and interpolates their values", () => {
665
+ const rendered = render(
666
+ <ResponseCell
667
+ response={{
668
+ type: "field_validation_error",
669
+ details: {
670
+ implementation_key: "implementation-key",
671
+ errors: [
672
+ {
673
+ name: "value.out_of_range",
674
+ message: "Invalid value",
675
+ field: "threshold",
676
+ values: { min: 10, max: 20 },
677
+ },
678
+ ],
679
+ },
680
+ }}
681
+ status="ERROR"
682
+ />,
683
+ {
684
+ messages: {
685
+ en: {
686
+ "implementation.error.value.out_of_range":
687
+ "{field} must be between {min} and {max}",
688
+ },
689
+ },
690
+ },
691
+ );
692
+
693
+ expect(
694
+ rendered.getByText("threshold must be between 10 and 20"),
695
+ ).toBeInTheDocument();
696
+ });
697
+
613
698
  it("renders ERROR status with field_validation_error details as an object", () => {
614
699
  const response = {
615
700
  type: "field_validation_error",
@@ -55,7 +55,7 @@ import SideMenu from "./SideMenu";
55
55
  import StructureFilterItem from "./StructureFilterItem";
56
56
  import Submenu from "./Submenu";
57
57
  import TaxonomyMenu from "./TaxonomyMenu";
58
- import TemplateSelector from "./TemplateSelector";
58
+ import TemplateSelector, { TemplateLoadError } from "./TemplateSelector";
59
59
  import TreeSelector from "./TreeSelector";
60
60
  import Unauthorized from "./Unauthorized";
61
61
  import UploadModal from "./UploadModal";
@@ -119,6 +119,7 @@ export {
119
119
  StructureFilterItem,
120
120
  Submenu,
121
121
  TaxonomyMenu,
122
+ TemplateLoadError,
122
123
  TemplateSelector,
123
124
  TreeSelector,
124
125
  TrueModal,
@@ -0,0 +1,77 @@
1
+ import { toErrorList, phoenixFailure } from "../message";
2
+
3
+ describe("errors whose entries are objects", () => {
4
+ it("does not stringify an object into the key", () => {
5
+ const [error] = toErrorList("checkoutGrantRequest.error")({
6
+ requests: [{ data_structure_id: 42 }],
7
+ });
8
+
9
+ expect(error.name).not.toContain("[object Object]");
10
+ });
11
+
12
+ it("falls back to the key itself when the object carries no message", () => {
13
+ const [error] = toErrorList("checkoutGrantRequest.error")({
14
+ requests: [{ data_structure_id: 42 }],
15
+ });
16
+
17
+ expect(error.name).toBe("checkoutGrantRequest.error.requests");
18
+ });
19
+
20
+ it("uses a message when the object carries one", () => {
21
+ const [error] = toErrorList("checkoutGrantRequest.error")({
22
+ requests: [{ message: "user_id is required" }],
23
+ });
24
+
25
+ expect(error.name).toBe(
26
+ "checkoutGrantRequest.error.requests.user_id is required",
27
+ );
28
+ });
29
+
30
+ it("keeps plain string entries working as before", () => {
31
+ const errors = toErrorList("checkoutGrantRequest.error")({
32
+ user_id: ["can't be blank"],
33
+ });
34
+
35
+ expect(errors).toEqual([
36
+ { name: "checkoutGrantRequest.error.user_id.can't be blank" },
37
+ ]);
38
+ });
39
+
40
+ it("a phoenix failure with an object entry does not leak it either", () => {
41
+ const failure = phoenixFailure("CHECKOUT_GRANT_REQUEST/FAILURE", {
42
+ requests: [{ data_structure_id: 42 }],
43
+ });
44
+
45
+ expect(JSON.stringify(failure)).not.toContain("[object Object]");
46
+ });
47
+ });
48
+
49
+ describe("both changeset error shapes", () => {
50
+ it("passes through a list the back end already normalised", () => {
51
+ const failure = phoenixFailure("RULE_CREATE/FAILURE", [
52
+ {
53
+ name: "rule.error.errors.date_restrictions.out_of_range",
54
+ field: "fecha max fija",
55
+ values: { limit: "2026-06-30" },
56
+ },
57
+ ]);
58
+
59
+ expect(failure.errorList).toEqual([
60
+ {
61
+ name: "rule.error.errors.date_restrictions.out_of_range",
62
+ field: "fecha max fija",
63
+ values: { limit: "2026-06-30" },
64
+ },
65
+ ]);
66
+ });
67
+
68
+ it("still builds keys from the older object shape", () => {
69
+ const failure = phoenixFailure("RULE_CREATE/FAILURE", {
70
+ name: ["can't be blank"],
71
+ });
72
+
73
+ expect(failure.errorList).toEqual([
74
+ { name: "ruleCreate.error.name.can't be blank" },
75
+ ]);
76
+ });
77
+ });
@@ -4,7 +4,9 @@ const isErrorsFailure = (status, data) =>
4
4
  status === 422 && _.conformsTo({ errors: _.isArray })(data);
5
5
 
6
6
  const isPhoenixFailure = (status, data) =>
7
- status === 422 && _.conformsTo({ errors: _.isObject })(data);
7
+ status === 422 &&
8
+ (_.conformsTo({ errors: _.isObject })(data) ||
9
+ _.conformsTo({ errors: _.isArray })(data));
8
10
 
9
11
  const isDefaultFailure = (status) => {
10
12
  return !!status;
@@ -79,14 +81,32 @@ export const graphQLErrors = (payload) => {
79
81
  };
80
82
  };
81
83
 
82
- export const toErrorList = (prefix) =>
83
- _.flow(
84
+ const errorKeyPart = (entry) => {
85
+ if (_.isString(entry)) return entry;
86
+ if (_.isObject(entry)) return entry.message || "";
87
+ return String(entry ?? "");
88
+ };
89
+
90
+ export const toErrorList = (prefix) => (errors) => {
91
+ if (_.isArray(errors)) {
92
+ return errors.map((error) => ({
93
+ name: error.name,
94
+ ...(error.field ? { field: error.field } : {}),
95
+ ...(_.isEmpty(error.values) ? {} : { values: error.values }),
96
+ }));
97
+ }
98
+
99
+ return _.flow(
84
100
  _.toPairs,
85
- _.flatMap(([field, errors]) =>
86
- _.map((v) => `${prefix}.${field}.${v}`)(errors),
101
+ _.flatMap(([field, errorList]) =>
102
+ _.map((entry) => {
103
+ const part = errorKeyPart(entry);
104
+ return part ? `${prefix}.${field}.${part}` : `${prefix}.${field}`;
105
+ })(errorList),
87
106
  ),
88
107
  _.map((name) => ({ name })),
89
- );
108
+ )(errors);
109
+ };
90
110
 
91
111
  export const phoenixFailure = (type, errors) => {
92
112
  const action = _.flow(_.replace("/FAILURE", ""), _.camelCase)(type);