@truedat/core 8.13.4 → 8.13.6

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.4",
3
+ "version": "8.13.6",
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.4",
60
+ "@truedat/test": "8.13.6",
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": "4ec142f1c856aa669b232b12250c800a19ab9ace"
102
+ "gitHead": "c0943c8cf31e82a81fdca24d68ef57406fe5bcbc"
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",
@@ -15,7 +15,7 @@ import {
15
15
  CONCEPTS_DEPRECATED,
16
16
  CONCEPT_LINKS_MANAGEMENT,
17
17
  CONCEPT_LINKS_APPROVALS,
18
- CONCEPTS_BULK_UPLOAD_EVENTS,
18
+ CONCEPTS_UPLOAD_JOBS,
19
19
  CONCEPTS_SUBSCOPE,
20
20
  CONCEPTS_SIDEMENU_SUBSCOPE,
21
21
  CONCEPTS_SIDEMENU_SUBSCOPE_DEPRECATED,
@@ -101,9 +101,9 @@ function buildMainConceptsSubmenuItems(
101
101
  };
102
102
  const manageUploads = {
103
103
  name: "concepts_upload_events",
104
- routes: [CONCEPTS_BULK_UPLOAD_EVENTS],
104
+ routes: [CONCEPTS_UPLOAD_JOBS],
105
105
  groups: ["business_glossary_management"],
106
- isActive: () => pathname === CONCEPTS_BULK_UPLOAD_EVENTS,
106
+ isActive: () => pathname === CONCEPTS_UPLOAD_JOBS,
107
107
  };
108
108
 
109
109
  return [
@@ -171,7 +171,7 @@ function buildManagementSubmenuItems() {
171
171
  },
172
172
  {
173
173
  name: "concepts_upload_events",
174
- routes: [CONCEPTS_BULK_UPLOAD_EVENTS],
174
+ routes: [CONCEPTS_UPLOAD_JOBS],
175
175
  groups: ["business_glossary_management"],
176
176
  },
177
177
  ];
@@ -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 (
@@ -168,7 +168,7 @@ export const ChangesDetail = ({ changes, changeDetails, scope }) => {
168
168
  const detailsId = useId();
169
169
  const details = changeDetails ?? changes;
170
170
 
171
- if (!details) return null;
171
+ if (!details || _.isEmpty(details)) return null;
172
172
 
173
173
  const changesList = flattenChanges(details);
174
174
 
@@ -1,9 +1,11 @@
1
1
  import _ from "lodash/fp";
2
2
  import PropTypes from "prop-types";
3
+ import { Link } from "react-router";
3
4
  import { FormattedMessage, useIntl } from "react-intl";
4
5
  import Label from "semantic-ui-react/dist/commonjs/elements/Label";
5
6
  import RuleImplementationLink from "@truedat/dq/components/RuleImplementationLink";
6
7
  import StructureNoteLink from "@truedat/dd/components/StructureNoteLink";
8
+ import { linkTo } from "../routes";
7
9
  import { ChangesDetail, ErrorDetailsDisclosure } from "./UploadJobDetails";
8
10
  import { classifyUploadJobErrorMessage } from "./UploadJobSummary";
9
11
 
@@ -45,7 +47,13 @@ export const ResponseCell = ({
45
47
  );
46
48
  }
47
49
  case "ERROR":
48
- return <ErrorDetail response={response} showOrigin={showOrigin} />;
50
+ return (
51
+ <ErrorDetail
52
+ response={response}
53
+ showOrigin={showOrigin}
54
+ scope={scope}
55
+ />
56
+ );
49
57
  case "COMPLETED":
50
58
  return <CompletedDetail response={response} />;
51
59
  case "INFO":
@@ -69,6 +77,19 @@ export const ResponseCell = ({
69
77
  }
70
78
  };
71
79
 
80
+ const ConceptLink = ({ business_concept_id, id, name }) => (
81
+ <Link to={linkTo.CONCEPT_VERSION({ business_concept_id, id })}>{name}</Link>
82
+ );
83
+
84
+ ConceptLink.propTypes = {
85
+ business_concept_id: PropTypes.oneOfType([
86
+ PropTypes.number,
87
+ PropTypes.string,
88
+ ]),
89
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
90
+ name: PropTypes.string,
91
+ };
92
+
72
93
  export const ResultEntityCell = ({ response, status }) => {
73
94
  if (!response?.details || !["INFO", "ERROR"].includes(status)) return null;
74
95
 
@@ -78,6 +99,8 @@ export const ResultEntityCell = ({ response, status }) => {
78
99
 
79
100
  return response.details.implementation_key != null ? (
80
101
  <RuleImplementationLink {...response.details} />
102
+ ) : response.details.business_concept_id != null ? (
103
+ <ConceptLink {...response.details} />
81
104
  ) : status === "INFO" &&
82
105
  (response.details.data_structure_id != null ||
83
106
  response.details.external_id != null) ? (
@@ -113,6 +136,11 @@ ResultEntityCell.propTypes = {
113
136
  PropTypes.number,
114
137
  PropTypes.string,
115
138
  ]),
139
+ business_concept_id: PropTypes.oneOfType([
140
+ PropTypes.number,
141
+ PropTypes.string,
142
+ ]),
143
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
116
144
  name: PropTypes.string,
117
145
  }),
118
146
  }),
@@ -187,7 +215,7 @@ const externalIdDetailValue = (details) =>
187
215
  (details?.external_id ?? "")
188
216
  );
189
217
 
190
- const ErrorDetail = ({ response, showOrigin }) => {
218
+ const ErrorDetail = ({ response, showOrigin, scope }) => {
191
219
  const { formatMessage } = useIntl();
192
220
 
193
221
  if (!response || !response.type) {
@@ -196,6 +224,31 @@ const ErrorDetail = ({ response, showOrigin }) => {
196
224
  const { type } = response;
197
225
  const messagePrefix = "uploadJob.parser.detail";
198
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
+
199
252
  if (!showOrigin && type === "data_structure_not_found") {
200
253
  return (
201
254
  <span className="upload-job-simple-error">
@@ -204,6 +257,51 @@ const ErrorDetail = ({ response, showOrigin }) => {
204
257
  );
205
258
  }
206
259
 
260
+ if (!showOrigin && type === "name_not_available") {
261
+ const name = response.details?.name;
262
+
263
+ return (
264
+ <span className="upload-job-simple-error">
265
+ <FormattedMessage id="uploadJob.parser.error.name_not_available" />
266
+ {name != null ? `: ${name}` : null}
267
+ </span>
268
+ );
269
+ }
270
+
271
+ if (!showOrigin && type === "unexpected_error") {
272
+ const { type: errorType } = classifyUploadJobErrorMessage(
273
+ response.details?.message,
274
+ );
275
+
276
+ if (errorType === "unexpected_error") {
277
+ return (
278
+ <span className="upload-job-simple-error">
279
+ <FormattedMessage id="uploadJob.parser.error.unexpected_error" />
280
+ </span>
281
+ );
282
+ }
283
+ }
284
+
285
+ const validationErrorPairs = (errors, details) => {
286
+ if (_.isArray(errors)) {
287
+ return errors.map((e) => [
288
+ e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
289
+ translatedFieldError(e, details),
290
+ ]);
291
+ }
292
+
293
+ if (_.isObject(errors)) {
294
+ return Object.entries(errors).flatMap(([field, messages]) =>
295
+ (_.isArray(messages) ? messages : [messages]).map((message) => [
296
+ field,
297
+ message ?? "",
298
+ ]),
299
+ );
300
+ }
301
+
302
+ return null;
303
+ };
304
+
207
305
  const detailBuilders = {
208
306
  missing_required_headers: (details) => [
209
307
  [
@@ -276,10 +374,9 @@ const ErrorDetail = ({ response, showOrigin }) => {
276
374
  [null, externalIdDetailValue(details)],
277
375
  ].filter(([, v]) => v !== undefined && v !== ""),
278
376
  field_validation_error: (details) =>
279
- details?.errors?.map((e) => [
280
- e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
281
- e?.message ?? "",
282
- ]) ?? [[null, externalIdDetailValue(details)]],
377
+ validationErrorPairs(details?.errors, details) ?? [
378
+ [null, externalIdDetailValue(details)],
379
+ ],
283
380
  unprocessable_entity: (details) => [[null, externalIdDetailValue(details)]],
284
381
  structure_note_creation_error: (details) => {
285
382
  const reason = details?.reason ?? details?.error;
@@ -289,12 +386,10 @@ const ErrorDetail = ({ response, showOrigin }) => {
289
386
  ? `structures.uploadStructures.failed.note.${reason}`
290
387
  : null;
291
388
 
292
- const errorPairs = details?.errors?.length
293
- ? details.errors.map((e) => [
294
- e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
295
- e?.message ?? "",
296
- ])
297
- : [
389
+ const validationPairs = validationErrorPairs(details?.errors, details);
390
+
391
+ const errorPairs = _.isEmpty(validationPairs)
392
+ ? [
298
393
  [
299
394
  formatMessage({ id: `${messagePrefix}.reason` }),
300
395
  reasonMessageId
@@ -304,7 +399,8 @@ const ErrorDetail = ({ response, showOrigin }) => {
304
399
  })
305
400
  : String(reason ?? ""),
306
401
  ],
307
- ];
402
+ ]
403
+ : validationPairs;
308
404
 
309
405
  const externalIdPair = details?.external_id
310
406
  ? [[null, externalIdDetailValue(details)]]
@@ -1,10 +1,19 @@
1
1
  import {
2
+ CONCEPTS_UPLOAD_JOBS,
2
3
  IMPLEMENTATIONS_UPLOAD_JOBS,
3
4
  STRUCTURE_NOTES_UPLOAD_JOBS,
4
5
  linkTo,
5
6
  } from "@truedat/core/routes";
6
7
 
7
8
  export const uploadJobScopes = {
9
+ concepts: {
10
+ listPath: CONCEPTS_UPLOAD_JOBS,
11
+ detailPath: (id) => linkTo.CONCEPTS_UPLOAD_JOB({ id }),
12
+ listHeaderId: "uploadJobs.concepts.header",
13
+ detailHeaderId: "uploadJob.concepts.header",
14
+ entityColumnMessageId: "uploadJob.results.column.concept",
15
+ fieldMessagePrefix: "concepts.props",
16
+ },
8
17
  implementations: {
9
18
  listPath: IMPLEMENTATIONS_UPLOAD_JOBS,
10
19
  detailPath: (id) => linkTo.IMPLEMENTATIONS_UPLOAD_JOB({ id }),
@@ -52,7 +52,8 @@ export const classifyUploadJobErrorMessage = (message) => {
52
52
  const isTechnicalError =
53
53
  /^%[A-Z][A-Za-z0-9_.]*\{/.test(normalizedMessage) ||
54
54
  /^\{:/.test(normalizedMessage) ||
55
- /^\*\* \(/.test(normalizedMessage);
55
+ /^\*\* \(/.test(normalizedMessage) ||
56
+ /^protocol \S+ not implemented/.test(normalizedMessage);
56
57
 
57
58
  return isTechnicalError
58
59
  ? { type: "unexpected_error" }
@@ -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 () => {
@@ -37,4 +37,14 @@ describe("ImplementationUploadJobBreadcrumbs", () => {
37
37
  expect(rendered.getByText(filename)).toBeInTheDocument();
38
38
  expect(rendered.container).toMatchSnapshot();
39
39
  });
40
+
41
+ it("renders breadcrumbs for concepts scope", async () => {
42
+ const rendered = render(<UploadJobBreadcrumbs scope="concepts" />);
43
+ await waitForLoad(rendered);
44
+
45
+ expect(
46
+ rendered.getByText(/uploadJobs.concepts.header/i),
47
+ ).toBeInTheDocument();
48
+ expect(rendered.container).toMatchSnapshot();
49
+ });
40
50
  });
@@ -32,6 +32,10 @@ jest.mock("@truedat/dd/components/StructureNoteLink", () => ({
32
32
  );
33
33
  },
34
34
  }));
35
+ jest.mock("react-router", () => ({
36
+ ...jest.requireActual("react-router"),
37
+ Link: ({ to, children }) => <a href={to}>{children}</a>,
38
+ }));
35
39
 
36
40
  const changeMessages = {
37
41
  en: {
@@ -50,6 +54,8 @@ const changeMessages = {
50
54
  "uploadJob.parser.error.field_validation_error": "Field validation error",
51
55
  "uploadJob.parser.error.data_structure_not_found":
52
56
  "Data structure not found",
57
+ "uploadJob.parser.error.unexpected_error": "Unexpected error",
58
+ "uploadJob.parser.error.name_not_available": "Name not available",
53
59
  "uploadJob.parser.detail.message": "Message",
54
60
  },
55
61
  };
@@ -99,6 +105,98 @@ describe("<ResultEntityCell />", () => {
99
105
  );
100
106
  });
101
107
 
108
+ it("renders INFO concept links from row details", () => {
109
+ const rendered = render(
110
+ <ResultEntityCell
111
+ status="INFO"
112
+ response={{
113
+ details: {
114
+ id: 5,
115
+ business_concept_id: 3,
116
+ name: "My Concept",
117
+ },
118
+ }}
119
+ />,
120
+ );
121
+ expect(rendered.getByRole("link", { name: "My Concept" })).toHaveAttribute(
122
+ "href",
123
+ "/concepts/3/versions/5",
124
+ );
125
+ });
126
+
127
+ it("renders ERROR concept links from row details", () => {
128
+ const rendered = render(
129
+ <ResultEntityCell
130
+ status="ERROR"
131
+ response={{
132
+ type: "name_not_available",
133
+ details: {
134
+ id: 5,
135
+ business_concept_id: 3,
136
+ name: "My Concept",
137
+ },
138
+ }}
139
+ />,
140
+ );
141
+ expect(rendered.getByRole("link", { name: "My Concept" })).toHaveAttribute(
142
+ "href",
143
+ "/concepts/3/versions/5",
144
+ );
145
+ });
146
+
147
+ it("renders concept links for unexpected_error rows with entity data", () => {
148
+ const rendered = render(
149
+ <ResultEntityCell
150
+ status="ERROR"
151
+ response={{
152
+ type: "unexpected_error",
153
+ details: {
154
+ id: 5,
155
+ business_concept_id: 3,
156
+ name: "My Concept",
157
+ },
158
+ }}
159
+ />,
160
+ );
161
+ expect(rendered.getByRole("link", { name: "My Concept" })).toHaveAttribute(
162
+ "href",
163
+ "/concepts/3/versions/5",
164
+ );
165
+ });
166
+
167
+ it("renders concept links for unchanged rows", () => {
168
+ const rendered = render(
169
+ <ResultEntityCell
170
+ status="INFO"
171
+ response={{
172
+ type: "unchanged",
173
+ details: {
174
+ id: 5,
175
+ business_concept_id: 3,
176
+ name: "My Concept",
177
+ },
178
+ }}
179
+ />,
180
+ );
181
+ expect(rendered.getByRole("link", { name: "My Concept" })).toHaveAttribute(
182
+ "href",
183
+ "/concepts/3/versions/5",
184
+ );
185
+ });
186
+
187
+ it("renders no entity for historical unchanged rows without entity data", () => {
188
+ const rendered = render(
189
+ <ResultEntityCell
190
+ status="INFO"
191
+ response={{
192
+ type: "unchanged",
193
+ details: { row_number: 3, sheet: "date template" },
194
+ }}
195
+ />,
196
+ );
197
+ expect(rendered.container).toBeEmptyDOMElement();
198
+ });
199
+
102
200
  it("renders implementation refs and preferred structure labels with external ids", () => {
103
201
  const implementation = render(
104
202
  <ResultEntityCell
@@ -512,6 +610,128 @@ describe("<ResponseCell />", () => {
512
610
  expect(rendered.getByText(/Already exists/i)).toBeInTheDocument();
513
611
  });
514
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
+
698
+ it("renders ERROR status with field_validation_error details as an object", () => {
699
+ const response = {
700
+ type: "field_validation_error",
701
+ details: {
702
+ id: 693,
703
+ name: "prueba2",
704
+ sheet: "termino",
705
+ errors: { adjuntos: ["is invalid"] },
706
+ row_number: 3,
707
+ business_concept_id: 337,
708
+ },
709
+ };
710
+ const rendered = render(
711
+ <ResponseCell response={response} status="ERROR" />,
712
+ );
713
+ expect(
714
+ rendered.getByText(/uploadJob.parser.error.field_validation_error/i),
715
+ ).toBeInTheDocument();
716
+ expect(rendered.getByText(/adjuntos/i)).toBeInTheDocument();
717
+ expect(rendered.getByText(/is invalid/i)).toBeInTheDocument();
718
+ });
719
+
720
+ it("renders ERROR status with field_validation_error object details with multiple messages", () => {
721
+ const response = {
722
+ type: "field_validation_error",
723
+ details: {
724
+ errors: { name: ["Required", "Too long"], type: "is invalid" },
725
+ },
726
+ };
727
+ const rendered = render(
728
+ <ResponseCell response={response} status="ERROR" />,
729
+ );
730
+ expect(rendered.getByText(/Required/i)).toBeInTheDocument();
731
+ expect(rendered.getByText(/Too long/i)).toBeInTheDocument();
732
+ expect(rendered.getByText(/is invalid/i)).toBeInTheDocument();
733
+ });
734
+
515
735
  it("renders ERROR status with deprecated_implementation details", () => {
516
736
  const response = {
517
737
  type: "deprecated_implementation",
@@ -706,6 +926,27 @@ describe("<ResponseCell />", () => {
706
926
  });
707
927
  });
708
928
 
929
+ it("renders no changes disclosure when change_details is empty", () => {
930
+ const rendered = render(
931
+ <ResponseCell
932
+ response={{
933
+ type: "updated",
934
+ details: {
935
+ change_details: {},
936
+ },
937
+ }}
938
+ status="INFO"
939
+ />,
940
+ {
941
+ messages: changeMessages,
942
+ },
943
+ );
944
+
945
+ expect(
946
+ rendered.queryByRole("button", { name: /modified fields?/i }),
947
+ ).not.toBeInTheDocument();
948
+ });
949
+
709
950
  it("renders boolean, null and zero change values without losing their values", async () => {
710
951
  const user = userEvent.setup({ delay: null });
711
952
  const rendered = render(
@@ -850,6 +1091,67 @@ describe("<ResultDetailCell /> errors", () => {
850
1091
  expect(rendered.queryByText("unknown-note")).not.toBeInTheDocument();
851
1092
  });
852
1093
 
1094
+ it("renders unexpected_error with empty details as a generic message only", () => {
1095
+ const rendered = render(
1096
+ <ResultDetailCell
1097
+ response={{ type: "unexpected_error", details: {} }}
1098
+ status="ERROR"
1099
+ />,
1100
+ { messages: changeMessages },
1101
+ );
1102
+
1103
+ expect(rendered.getByText("Unexpected error")).toBeInTheDocument();
1104
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
1105
+ expect(
1106
+ rendered.queryByText(/protocol String\.Chars/),
1107
+ ).not.toBeInTheDocument();
1108
+ });
1109
+
1110
+ it("renders unexpected_error events with technical messages as a generic message only", () => {
1111
+ const technicalMessage =
1112
+ "protocol String.Chars not implemented for type Tuple. This protocol is implemented for the following type(s): Atom, BitString\n\nGot value:\n\n {:array, :map}\n";
1113
+
1114
+ const rendered = render(
1115
+ <ResultDetailCell
1116
+ response={{
1117
+ type: "unexpected_error",
1118
+ details: { message: technicalMessage },
1119
+ }}
1120
+ status="ERROR"
1121
+ />,
1122
+ { messages: changeMessages },
1123
+ );
1124
+
1125
+ expect(rendered.getByText("Unexpected error")).toBeInTheDocument();
1126
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
1127
+ expect(
1128
+ rendered.queryByText(/protocol String\.Chars/),
1129
+ ).not.toBeInTheDocument();
1130
+ });
1131
+
1132
+ it("renders name_not_available with the conflicting name as a simple message", () => {
1133
+ const rendered = render(
1134
+ <ResultDetailCell
1135
+ response={{
1136
+ type: "name_not_available",
1137
+ details: {
1138
+ name: "TEST CREACION",
1139
+ row_number: 3,
1140
+ sheet: "date template",
1141
+ },
1142
+ }}
1143
+ status="ERROR"
1144
+ />,
1145
+ { messages: changeMessages },
1146
+ );
1147
+
1148
+ expect(
1149
+ rendered.getByText("Name not available: TEST CREACION"),
1150
+ ).toBeInTheDocument();
1151
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
1152
+ expect(rendered.queryByText(/row_number/)).not.toBeInTheDocument();
1153
+ });
1154
+
853
1155
  it("uses the singular error count for one field", () => {
854
1156
  const rendered = render(
855
1157
  <ResultDetailCell
@@ -25,6 +25,23 @@ describe("Upload job scopes", () => {
25
25
  );
26
26
  });
27
27
 
28
+ it("defines concepts upload routes and presentation metadata", () => {
29
+ expect(uploadJobScopes.concepts.listPath).toBe("/concepts/uploadJobs");
30
+ expect(uploadJobScopes.concepts.listHeaderId).toBe(
31
+ "uploadJobs.concepts.header",
32
+ );
33
+ expect(uploadJobScopes.concepts.detailHeaderId).toBe(
34
+ "uploadJob.concepts.header",
35
+ );
36
+ expect(uploadJobScopes.concepts.detailPath(12)).toBe(
37
+ "/concepts/uploadJobs/12",
38
+ );
39
+ expect(uploadJobScopes.concepts.entityColumnMessageId).toBe(
40
+ "uploadJob.results.column.concept",
41
+ );
42
+ expect(uploadJobScopes.concepts.fieldMessagePrefix).toBe("concepts.props");
43
+ });
44
+
28
45
  it("falls back to implementation semantics for unknown scopes", () => {
29
46
  expect(getUploadJobScope("other")).toMatchObject({
30
47
  listPath: null,
@@ -130,6 +130,33 @@ describe("<UploadJobSummary />", () => {
130
130
  });
131
131
  });
132
132
 
133
+ it("classifies protocol not implemented messages as unexpected errors", () => {
134
+ expect(
135
+ classifyUploadJobErrorMessage(
136
+ "protocol String.Chars not implemented for type Tuple. This protocol is implemented for the following type(s): Atom, BitString\n\nGot value:\n\n {:array, :map}\n",
137
+ ),
138
+ ).toEqual({ type: "unexpected_error" });
139
+ });
140
+
141
+ it("replaces protocol failure details with an unexpected error", () => {
142
+ const technicalMessage =
143
+ "protocol String.Chars not implemented for type Tuple. This protocol is implemented for the following type(s): Atom, BitString\n\nGot value:\n\n {:array, :map}\n";
144
+ const rendered = render(
145
+ <UploadJobSummary
146
+ events={[{ status: "FAILED", response: { message: technicalMessage } }]}
147
+ latestStatus="FAILED"
148
+ />,
149
+ { messages: summaryMessages },
150
+ );
151
+
152
+ expect(rendered.getByLabelText("Summary")).toHaveTextContent(
153
+ "Error processing the file: Unexpected error",
154
+ );
155
+ expect(rendered.getByLabelText("Summary")).not.toHaveTextContent(
156
+ "protocol String.Chars not implemented",
157
+ );
158
+ });
159
+
133
160
  it("renders a human failure message", () => {
134
161
  const rendered = render(
135
162
  <UploadJobSummary
@@ -189,4 +189,19 @@ describe("<UploadJobs />", () => {
189
189
  expect(rendered.getByText(/completed/i)).toBeInTheDocument();
190
190
  expect(rendered.getByText(/success/i)).toBeInTheDocument();
191
191
  });
192
+
193
+ it("renders concepts scope header", async () => {
194
+ useUploadJobs.mockReturnValue({
195
+ loading: false,
196
+ data: {
197
+ data: [],
198
+ },
199
+ });
200
+
201
+ const rendered = render(<UploadJobs scope="concepts" />);
202
+ await waitForLoad(rendered);
203
+
204
+ const elements = rendered.getAllByText(/uploadJobs.concepts.header/i);
205
+ expect(elements.length).toBeGreaterThan(0);
206
+ });
192
207
  });
@@ -132,7 +132,7 @@ exports[`<GlossaryMenu /> matches the latest snapshot, finds fixed and subscopes
132
132
  aria-checked="false"
133
133
  class="item"
134
134
  data-discover="true"
135
- href="/concepts/management/bulk_upload_event"
135
+ href="/concepts/uploadJobs"
136
136
  name="concepts_upload_events"
137
137
  role="option"
138
138
  >
@@ -125,7 +125,7 @@ exports[`<SideMenu /> matches the latest snapshot 1`] = `
125
125
  aria-checked="false"
126
126
  class="item"
127
127
  data-discover="true"
128
- href="/concepts/management/bulk_upload_event"
128
+ href="/concepts/uploadJobs"
129
129
  name="concepts_upload_events"
130
130
  role="option"
131
131
  >
@@ -1,5 +1,21 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
+ exports[`ImplementationUploadJobBreadcrumbs renders breadcrumbs for concepts scope 1`] = `
4
+ <div>
5
+ <div
6
+ class="ui breadcrumb"
7
+ >
8
+ <a
9
+ class="section"
10
+ data-discover="true"
11
+ href="/concepts/uploadJobs"
12
+ >
13
+ uploadJobs.concepts.header
14
+ </a>
15
+ </div>
16
+ </div>
17
+ `;
18
+
3
19
  exports[`ImplementationUploadJobBreadcrumbs renders breadcrumbs with filename 1`] = `
4
20
  <div>
5
21
  <div
@@ -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,
package/src/routes.js CHANGED
@@ -14,8 +14,8 @@ export const BUCKET_VIEW_CONFIG_EDIT = "/bucketViewConfigs/:id/edit";
14
14
  export const BUCKET_VIEW_CONFIG_NEW = "/bucketViewConfigs/new";
15
15
  export const CONCEPTS = "/concepts";
16
16
  export const CONCEPTS_BULK_UPDATE = "/concepts/bulk_update";
17
- export const CONCEPTS_BULK_UPLOAD_EVENTS =
18
- "/concepts/management/bulk_upload_event";
17
+ export const CONCEPTS_UPLOAD_JOBS = "/concepts/uploadJobs";
18
+ export const CONCEPTS_UPLOAD_JOB = "/concepts/uploadJobs/:id";
19
19
  export const CONCEPTS_NEW = "/concepts/new";
20
20
  export const CONCEPTS_PENDING = "/concepts/pending";
21
21
  export const CONCEPTS_DEPRECATED = "/concepts/deprecated";
@@ -313,6 +313,8 @@ const routes = {
313
313
  BUCKET_VIEW_CONFIGS,
314
314
  CONCEPTS,
315
315
  CONCEPTS_BULK_UPDATE,
316
+ CONCEPTS_UPLOAD_JOBS,
317
+ CONCEPTS_UPLOAD_JOB,
316
318
  CONCEPT_LINKS_APPROVALS,
317
319
  CONCEPT_LINKS_APPROVALS_RESULTS,
318
320
  CONCEPTS_NEW,
@@ -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);