@truedat/core 8.1.3 → 8.1.4

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.1.3",
3
+ "version": "8.1.4",
4
4
  "description": "Truedat Web Core",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -48,7 +48,7 @@
48
48
  "@testing-library/jest-dom": "^6.6.3",
49
49
  "@testing-library/react": "^16.3.0",
50
50
  "@testing-library/user-event": "^14.6.1",
51
- "@truedat/test": "8.1.3",
51
+ "@truedat/test": "8.1.4",
52
52
  "identity-obj-proxy": "^3.0.0",
53
53
  "jest": "^29.7.0",
54
54
  "redux-saga-test-plan": "^4.0.6"
@@ -85,5 +85,5 @@
85
85
  "slate-react": "^0.22.10",
86
86
  "swr": "^2.3.3"
87
87
  },
88
- "gitHead": "0eaf0c4b1342771cddb87d09ed78a9e4c65a5ca2"
88
+ "gitHead": "49d0d43e081d0f631f221d8f5992e390098b3630"
89
89
  }
package/src/api.js CHANGED
@@ -14,3 +14,5 @@ export const API_ACL_RESOURCE_ENTRIES = "/api/acl_entries/:type/:id";
14
14
  export const API_HIERARCHIES = "/api/hierarchies";
15
15
  export const API_HIERARCHY = "/api/hierarchies/:id";
16
16
  export const API_SYSTEMS = "/api/systems";
17
+ export const API_UPLOAD_JOBS = "/api/upload_jobs";
18
+ export const API_UPLOAD_JOB = "/api/upload_jobs/:id";
@@ -10,7 +10,7 @@ import {
10
10
  PENDING_STRUCTURE_NOTES,
11
11
  REFERENCE_DATASETS,
12
12
  STRUCTURES,
13
- STRUCTURES_UPLOAD_EVENTS,
13
+ STRUCTURE_NOTES_UPLOAD_JOBS,
14
14
  STRUCTURE_TYPES,
15
15
  SYSTEMS,
16
16
  STRUCTURE_TAGS,
@@ -29,7 +29,7 @@ const adminItems = [
29
29
 
30
30
  const structureNoteItems = [
31
31
  { name: "pending_structure_notes", routes: [PENDING_STRUCTURE_NOTES] },
32
- { name: "structures_upload_events", routes: [STRUCTURES_UPLOAD_EVENTS] },
32
+ { name: "structures_upload_events", routes: [STRUCTURE_NOTES_UPLOAD_JOBS] },
33
33
  ];
34
34
 
35
35
  const catalogViewConfigItems = (formatMessage) =>
@@ -0,0 +1,217 @@
1
+ import _ from "lodash/fp";
2
+ import { useState } from "react";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { useParams } from "react-router";
5
+ import {
6
+ Header,
7
+ Icon,
8
+ Segment,
9
+ Dimmer,
10
+ Loader,
11
+ Table,
12
+ Label,
13
+ Button,
14
+ } from "semantic-ui-react";
15
+ import { DateTime } from "@truedat/core/components";
16
+ import { useUploadJob } from "../hooks/useUploadJobs";
17
+ import { StatusPill, ResponseCell } from "./UploadJobParser";
18
+ import UploadJobBreadcrumbs from "./UploadJobBreadcrumbs";
19
+
20
+ export default function UploadJob({ scope }) {
21
+ const { id } = useParams();
22
+ const { data, loading } = useUploadJob(id);
23
+ const job = data?.data;
24
+
25
+ const { formatMessage } = useIntl();
26
+ const [expandedGroups, setExpandedGroups] = useState({});
27
+
28
+ // Group events by status
29
+ const groupedEvents = job?.events ? _.groupBy("status", job.events) : {};
30
+
31
+ const toggleGroup = (status) => {
32
+ setExpandedGroups((prev) => ({
33
+ ...prev,
34
+ [status]: !prev[status],
35
+ }));
36
+ };
37
+
38
+ const renderEventGroup = (status, events) => {
39
+ const isExpanded = expandedGroups[status];
40
+ const shouldCollapse = status === "INFO" || status === "ERROR";
41
+
42
+ if (!shouldCollapse) {
43
+ // Render non-collapsible events normally
44
+ return events.map((event, idx) => (
45
+ <Table.Row key={`${status}-${idx}`}>
46
+ <Table.Cell>
47
+ <StatusPill status={event.status} />
48
+ </Table.Cell>
49
+ <Table.Cell>
50
+ <DateTime value={event.inserted_at} />
51
+ </Table.Cell>
52
+ <Table.Cell>
53
+ <ResponseCell {...event} />
54
+ </Table.Cell>
55
+ </Table.Row>
56
+ ));
57
+ }
58
+
59
+ // Render collapsible groups for INFO and ERROR
60
+ const groupRows = [];
61
+
62
+ // Add group header row
63
+ groupRows.push(
64
+ <Table.Row
65
+ key={`${status}-header`}
66
+ style={{ backgroundColor: "#f8f9fa" }}
67
+ >
68
+ <Table.Cell>
69
+ <Button
70
+ basic
71
+ size="mini"
72
+ icon={isExpanded ? "chevron down" : "chevron right"}
73
+ content={` ${formatMessage({ id: `uploadJob.event.status.${status}` })} (${events.length})`}
74
+ onClick={() => toggleGroup(status)}
75
+ style={{ padding: "0.5em" }}
76
+ />
77
+ </Table.Cell>
78
+ <Table.Cell>
79
+ <DateTime value={events[0]?.inserted_at} />
80
+ </Table.Cell>
81
+ <Table.Cell></Table.Cell>
82
+ </Table.Row>
83
+ );
84
+
85
+ // Add expanded events if group is expanded
86
+ if (isExpanded) {
87
+ events.forEach((event, idx) => {
88
+ groupRows.push(
89
+ <Table.Row key={`${status}-${idx}`} style={{ paddingLeft: "2em" }}>
90
+ <Table.Cell style={{ paddingLeft: "2em" }}>
91
+ <StatusPill status={event.status} />
92
+ </Table.Cell>
93
+ <Table.Cell>
94
+ <DateTime value={event.inserted_at} />
95
+ </Table.Cell>
96
+ <Table.Cell>
97
+ <ResponseCell {...event} />
98
+ </Table.Cell>
99
+ </Table.Row>
100
+ );
101
+ });
102
+ }
103
+
104
+ return groupRows;
105
+ };
106
+
107
+ return (
108
+ <>
109
+ <UploadJobBreadcrumbs filename={job?.filename} scope={scope} />
110
+ <Segment>
111
+ <Header as="h2">
112
+ <Icon
113
+ circular
114
+ name={formatMessage({
115
+ id: "uploadJob.header.icon",
116
+ defaultMessage: "cogs",
117
+ })}
118
+ />
119
+ <Header.Content>
120
+ <FormattedMessage id={`uploadJob.${scope}.header`} />
121
+ </Header.Content>
122
+ </Header>
123
+ <Dimmer.Dimmable dimmed={loading}>
124
+ <Segment attached="bottom">
125
+ {job ? (
126
+ <>
127
+ <div
128
+ style={{
129
+ display: "flex",
130
+ alignItems: "center",
131
+ gap: "1.5em",
132
+ marginBottom: "1.5em",
133
+ flexWrap: "wrap",
134
+ }}
135
+ >
136
+ <div style={{ display: "flex", flexDirection: "column" }}>
137
+ <span style={{ fontWeight: 600, fontSize: "1.3em" }}>
138
+ {job.filename}
139
+ </span>
140
+ <span style={{ color: "gray", fontSize: "0.7em" }}>
141
+ {job.hash}
142
+ </span>
143
+ </div>
144
+ <span>
145
+ <Label basic size="small" style={{ marginRight: 4 }}>
146
+ <FormattedMessage
147
+ id="uploadJob.table.status"
148
+ defaultMessage="Status"
149
+ />
150
+ </Label>
151
+ <StatusPill status={job.latest_status} />
152
+ </span>
153
+ <span>
154
+ <Label basic size="small" style={{ marginRight: 4 }}>
155
+ <FormattedMessage
156
+ id="uploadJob.table.inserted_at"
157
+ defaultMessage="Inserted At"
158
+ />
159
+ </Label>
160
+ <DateTime value={job.latest_event_at} />
161
+ </span>
162
+ </div>
163
+ <Table>
164
+ <Table.Header>
165
+ <Table.Row>
166
+ <Table.HeaderCell>
167
+ <FormattedMessage
168
+ id="uploadJob.table.status"
169
+ defaultMessage="Status"
170
+ />
171
+ </Table.HeaderCell>
172
+ <Table.HeaderCell>
173
+ <FormattedMessage
174
+ id="uploadJob.table.inserted_at"
175
+ defaultMessage="Inserted At"
176
+ />
177
+ </Table.HeaderCell>
178
+ <Table.HeaderCell>
179
+ <FormattedMessage
180
+ id="uploadJob.table.detail"
181
+ defaultMessage="Detail"
182
+ />
183
+ </Table.HeaderCell>
184
+ </Table.Row>
185
+ </Table.Header>
186
+ <Table.Body>
187
+ {_.isArray(job.events) && !_.isEmpty(job.events) ? (
188
+ Object.entries(groupedEvents)
189
+ .map(([status, events]) =>
190
+ renderEventGroup(status, events)
191
+ )
192
+ .flat()
193
+ ) : (
194
+ <Table.Row>
195
+ <Table.Cell colSpan={3}>
196
+ <FormattedMessage
197
+ id="uploadJob.table.no_events"
198
+ defaultMessage="No events found."
199
+ />
200
+ </Table.Cell>
201
+ </Table.Row>
202
+ )}
203
+ </Table.Body>
204
+ </Table>
205
+ </>
206
+ ) : null}
207
+ {loading ? (
208
+ <Dimmer active inverted>
209
+ <Loader />
210
+ </Dimmer>
211
+ ) : null}
212
+ </Segment>
213
+ </Dimmer.Dimmable>
214
+ </Segment>
215
+ </>
216
+ );
217
+ }
@@ -0,0 +1,38 @@
1
+ import PropTypes from "prop-types";
2
+ import { Breadcrumb } from "semantic-ui-react";
3
+ import { Link } from "react-router";
4
+ import { FormattedMessage } from "react-intl";
5
+ import { IMPLEMENTATIONS_UPLOAD_JOBS, STRUCTURE_NOTES_UPLOAD_JOBS } from "@truedat/core/routes";
6
+
7
+ export const UploadJobBreadcrumbs = ({ filename, scope }) => {
8
+ const uploadJobsPath = (scope) => {
9
+ switch (scope) {
10
+ case "implementations":
11
+ return IMPLEMENTATIONS_UPLOAD_JOBS;
12
+ case "notes":
13
+ return STRUCTURE_NOTES_UPLOAD_JOBS;
14
+ default:
15
+ return null;
16
+ }
17
+ };
18
+ return (
19
+ <Breadcrumb>
20
+ <Breadcrumb.Section as={Link} to={uploadJobsPath(scope)}>
21
+ <FormattedMessage id={`uploadJobs.${scope}.header`} />
22
+ </Breadcrumb.Section>
23
+ {filename ? (
24
+ <>
25
+ <Breadcrumb.Divider icon="right angle" />
26
+ <Breadcrumb.Section active>{filename}</Breadcrumb.Section>
27
+ </>
28
+ ) : null}
29
+ </Breadcrumb>
30
+ );
31
+ };
32
+
33
+ UploadJobBreadcrumbs.propTypes = {
34
+ filename: PropTypes.string,
35
+ scope: PropTypes.string.isRequired,
36
+ };
37
+
38
+ export default UploadJobBreadcrumbs;
@@ -0,0 +1,370 @@
1
+ import _ from "lodash/fp";
2
+ import { useState } from "react";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { Label } from "semantic-ui-react";
5
+ import { RichTextEditor } from "@truedat/core/components";
6
+
7
+ import RuleImplementationLink from "@truedat/dq/components/RuleImplementationLink";
8
+ import StructureNoteLink from "@truedat/dd/components/StructureNoteLink";
9
+
10
+ export const StatusPill = ({ status }) => (
11
+ <Label
12
+ color={_.cond([
13
+ [_.eq("COMPLETED"), _.constant("green")],
14
+ [_.eq("FAILED"), _.constant("red")],
15
+ [_.eq("ERROR"), _.constant("red")],
16
+ [_.eq("INFO"), _.constant("blue")],
17
+ [_.stubTrue, _.constant("grey")],
18
+ ])(status)}
19
+ size="small"
20
+ >
21
+ <FormattedMessage
22
+ id={`uploadJob.parser.event.status.${status}`}
23
+ />
24
+ </Label>
25
+ );
26
+
27
+ export const ResponseCell = ({ response, status }) => {
28
+ if (!response || (_.isObject(response) && _.isEmpty(response))) return null;
29
+
30
+ switch (status) {
31
+ case "FAILED":
32
+ return (
33
+ <FormattedMessage
34
+ id={`uploadJob.parser.error.${response.message}`}
35
+ defaultMessage={response.message}
36
+ />
37
+ );
38
+ case "ERROR":
39
+ return <ErrorDetail response={response} />;
40
+ case "COMPLETED":
41
+ return <CompletedDetail response={response} />;
42
+ case "INFO":
43
+ return <InfoDetail response={response} />;
44
+ default:
45
+ return (
46
+ <pre
47
+ style={{
48
+ whiteSpace: "pre-wrap",
49
+ wordBreak: "break-all",
50
+ margin: 0,
51
+ }}
52
+ >
53
+ {typeof response === "string"
54
+ ? response
55
+ : JSON.stringify(response, null, 2)}
56
+ </pre>
57
+ );
58
+ }
59
+ };
60
+
61
+ const SheetAndLine = ({ response }) => (
62
+ <Label size="small">
63
+ <span>{response.sheet}</span>
64
+ {response.row_number ? (
65
+ <span>
66
+ {" "}
67
+ -{" "}
68
+ <FormattedMessage id="uploadJob.parser.result.prop.row_number" />
69
+ : {response.row_number}
70
+ </span>
71
+ ) : null}
72
+ </Label>
73
+ );
74
+
75
+ const CellHeader = ({ header, response }) => (
76
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
77
+ <b>
78
+ <FormattedMessage id={header} />
79
+ </b>
80
+ <div>
81
+ <SheetAndLine response={response} />
82
+ </div>
83
+ </div>
84
+ );
85
+
86
+ const externalIdDetailValue = (details) =>
87
+ details?.data_structure_id != null && details?.external_id != null ? (
88
+ <StructureNoteLink
89
+ data_structure_id={details.data_structure_id}
90
+ external_id={details.external_id}
91
+ />
92
+ ) : (
93
+ details?.external_id ?? ""
94
+ );
95
+
96
+ const ErrorDetail = ({ response }) => {
97
+ const { formatMessage } = useIntl();
98
+
99
+ if (!response || !response.type) {
100
+ return null;
101
+ }
102
+ const { type } = response;
103
+ const messagePrefix = "uploadJob.parser.detail";
104
+
105
+ const detailBuilders = {
106
+ missing_required_headers: (details) => [
107
+ [
108
+ formatMessage({ id: `${messagePrefix}.headers` }),
109
+ details.missing_headers?.join(", "),
110
+ ],
111
+ ],
112
+ invalid_template_name: (details) => [
113
+ [
114
+ formatMessage({ id: `${messagePrefix}.templateName` }),
115
+ details.template_name,
116
+ ],
117
+ ],
118
+ invalid_domain_external_id: (details) => [
119
+ [
120
+ formatMessage({ id: `${messagePrefix}.domainExternalId` }),
121
+ details.domain_external_id,
122
+ ],
123
+ ],
124
+ invalid_associated_rule: (details) => [
125
+ [formatMessage({ id: `${messagePrefix}.ruleName` }), details.rule_name],
126
+ ],
127
+ deprecated_implementation: (details) => [
128
+ [
129
+ formatMessage({ id: `${messagePrefix}.itemKey` }),
130
+ details.implementation_key ?? details.item_key,
131
+ ],
132
+ ],
133
+ implementation_creation_error: (details) =>
134
+ details?.map(([field, [error]]) => [field, error]) || [],
135
+ duplicate_field_names: (details) => [
136
+ [
137
+ formatMessage({ id: `${messagePrefix}.duplicatedNames` }),
138
+ details?.duplicate_fields?.join(", "),
139
+ ],
140
+ ],
141
+ pending_approval_conflict: (details) =>
142
+ [
143
+ [formatMessage({ id: `${messagePrefix}.message` }), details?.message ?? ""],
144
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
145
+ ].filter(([, v]) => v !== undefined && v !== ""),
146
+ data_structure_not_found: (details) => [
147
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
148
+ ],
149
+ template_not_found: (details) => [
150
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
151
+ ],
152
+ unauthorized: (details) => [
153
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
154
+ ],
155
+ unreject_failed: (details) =>
156
+ [
157
+ [formatMessage({ id: `${messagePrefix}.reason` }), details?.reason ?? ""],
158
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
159
+ ].filter(([, v]) => v !== undefined && v !== ""),
160
+ field_validation_error: (details) =>
161
+ details?.errors?.map((e) => [
162
+ e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
163
+ e?.message ?? "",
164
+ ]) ?? [[formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)]],
165
+ unprocessable_entity: (details) => [
166
+ [formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)],
167
+ ],
168
+ structure_note_creation_error: (details) => {
169
+ const errorPairs = details?.errors?.length
170
+ ? details.errors.map((e) => [
171
+ e?.field ?? formatMessage({ id: `${messagePrefix}.message` }),
172
+ e?.message ?? "",
173
+ ])
174
+ : [[formatMessage({ id: `${messagePrefix}.reason` }), String(details?.reason ?? details?.error ?? "")]];
175
+ const externalIdPair = details?.external_id
176
+ ? [[formatMessage({ id: `${messagePrefix}.externalId` }), externalIdDetailValue(details)]]
177
+ : [];
178
+ return [...errorPairs, ...externalIdPair];
179
+ },
180
+ };
181
+
182
+ const detailBuilder = _.propOr(() => () => [], type)(detailBuilders);
183
+
184
+ const details = _.cond([
185
+ [_.isNil, _.constant([])],
186
+ [_.stubTrue, (d) => {
187
+ const result = detailBuilder(d);
188
+ return _.isArray(result) ? result : [];
189
+ }],
190
+ ])(response.details);
191
+
192
+ return (
193
+ <div>
194
+ <CellHeader
195
+ header={`uploadJob.parser.error.${type}`}
196
+ response={response}
197
+ />
198
+
199
+ {
200
+ _.isEmpty(details)
201
+ ? null
202
+ : details.map(([key, value], idx) => (
203
+ <div key={idx} style={{ paddingLeft: "10px" }}>
204
+ <span
205
+ style={{
206
+ fontSize: "1em",
207
+ fontWeight: "bold",
208
+ paddingRight: "5px",
209
+ }}
210
+ >
211
+ {key}:
212
+ </span>
213
+ {value}
214
+ </div>
215
+ ))
216
+ }
217
+ </div >
218
+ );
219
+ };
220
+ const summaryItems = [
221
+ {
222
+ key: "invalid_sheet_count",
223
+ messageId: "uploadJob.parser.result.summary.invalid_sheets",
224
+ },
225
+ {
226
+ key: "insert_count",
227
+ messageId: "uploadJob.parser.result.summary.created",
228
+ },
229
+ {
230
+ key: "update_count",
231
+ messageId: "uploadJob.parser.result.summary.updated",
232
+ },
233
+ {
234
+ key: "unchanged_count",
235
+ messageId: "uploadJob.parser.result.summary.unchanged",
236
+ },
237
+ {
238
+ key: "error_count",
239
+ messageId: "uploadJob.parser.result.summary.errors",
240
+ },
241
+ ];
242
+
243
+ const CompletedDetail = ({ response }) => {
244
+ const items = _.flow([
245
+ _.filter((item) => _.get(item.key, response) > 0),
246
+ (items) => {
247
+ const result = [];
248
+ for (let i = 0; i < items.length; i++) {
249
+ const item = items[i];
250
+ result.push(
251
+ <span key={item.key}>
252
+ <FormattedMessage id={item.messageId} />
253
+ {`: ${response[item.key]}`}
254
+ {i < items.length - 1 && <span>&nbsp;|&nbsp;</span>}
255
+ </span>
256
+ );
257
+ }
258
+ return result;
259
+ }
260
+ ])(summaryItems);
261
+
262
+ if (items.length === 0) return null;
263
+
264
+ return (
265
+ <span>&nbsp;|&nbsp;{items}&nbsp;|&nbsp;</span>
266
+ );
267
+ };
268
+
269
+
270
+ const InfoDetail = ({ response }) =>
271
+ !response || !response.type ? null : (
272
+ <div>
273
+ <CellHeader
274
+ header={`uploadJob.parser.info.${response.type}`}
275
+ response={response}
276
+ />
277
+ {response.details ? (
278
+ <div>
279
+ {response.details.implementation_key != null ? (
280
+ <RuleImplementationLink {...response.details} />
281
+ ) : (response.details.data_structure_id != null ||
282
+ response.details.external_id != null) ? (
283
+ <StructureNoteLink
284
+ data_structure_id={response.details.data_structure_id}
285
+ external_id={response.details.external_id}
286
+ />
287
+ ) : null}
288
+ {" "}
289
+ </div>
290
+ ) : null}
291
+ <ChangesDetail changes={response?.details?.changes} />
292
+ </div>
293
+ );
294
+ const ChangesDetail = ({
295
+ changes,
296
+ header = "uploadJob.parser.result.prop.changes",
297
+ }) => {
298
+ const [isExpanded, setIsExpanded] = useState(false);
299
+ if (!changes) return null;
300
+ const changesList = _.toPairs(changes);
301
+
302
+ return (
303
+ <div>
304
+ <div
305
+ onClick={() => setIsExpanded(!isExpanded)}
306
+ style={{
307
+ cursor: "pointer",
308
+ display: "flex",
309
+ gap: "5px",
310
+ alignItems: "center",
311
+ }}
312
+ >
313
+ <span>{isExpanded ? "▼" : "▶"}</span>
314
+ <span>
315
+ <FormattedMessage id={header} />
316
+ </span>
317
+ <span>({_.size(changesList)}) :</span>
318
+ </div>
319
+ {isExpanded &&
320
+ changesList.map(([key, value]) => (
321
+ <div key={key} style={{ paddingLeft: "10px" }}>
322
+ {key == "df_content" ? (
323
+ <ChangesDetail
324
+ changes={value}
325
+ header={`ruleImplementations.props.${key}`}
326
+ />
327
+ ) : (
328
+ <>
329
+ <span
330
+ style={{
331
+ fontSize: "1em",
332
+ fontWeight: "bold",
333
+ paddingRight: "5px",
334
+ }}
335
+ >
336
+ <FormattedMessage
337
+ id={`ruleImplementations.props.${key}`}
338
+ defaultMessage={key}
339
+ />
340
+ :
341
+ </span>
342
+ {formatValue(value, key)}
343
+ </>
344
+ )}
345
+ </div>
346
+ ))}
347
+ </div>
348
+ );
349
+ };
350
+
351
+
352
+ const formatValue = (value, key) => {
353
+ if (_.has("value")(value)) return formatValue(value.value, key);
354
+ if (_.isArray(value)) {
355
+ return _.flow(_.map((v) => formatValue(v, key)), _.join(", "))(value);
356
+ }
357
+ if (_.isObject(value)) {
358
+ if (_.has("document")(value))
359
+ return <RichTextEditor readOnly value={value} />;
360
+ if (_.has("url_value")(value))
361
+ return `[${value.url_name}] (${value.url_value})`;
362
+ if (_.has("name")(value))
363
+ return value.name;
364
+ if (_.has("external_id")(value))
365
+ return value.external_id;
366
+
367
+ return _.flow(_.keys, _.join(", "))(value);
368
+ }
369
+ return value;
370
+ };
@@ -0,0 +1,136 @@
1
+ import _ from "lodash/fp";
2
+ import moment from "moment";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { useNavigate } from "react-router";
5
+ import {
6
+ Header,
7
+ Icon,
8
+ Segment,
9
+ Dimmer,
10
+ Loader,
11
+ Table,
12
+ } from "semantic-ui-react";
13
+ import { linkTo } from "@truedat/core/routes";
14
+ import { useUploadJobs } from "../hooks/useUploadJobs";
15
+ import { StatusPill, ResponseCell } from "./UploadJobParser";
16
+ import UploadJobBreadcrumbs from "./UploadJobBreadcrumbs";
17
+
18
+ export default function UploadJobs({ scope }) {
19
+ const { formatMessage } = useIntl();
20
+ const { data, loading } = useUploadJobs(scope);
21
+ const jobs = data?.data;
22
+ const navigate = useNavigate();
23
+
24
+ const navigateToUploadJob = ({ id }) => {
25
+ switch (scope) {
26
+ case "implementations":
27
+ navigate(linkTo.IMPLEMENTATIONS_UPLOAD_JOB({ id }));
28
+ break;
29
+ case "notes":
30
+ navigate(linkTo.STRUCTURE_NOTES_UPLOAD_JOB({ id }));
31
+ break;
32
+ default:
33
+ break;
34
+ }
35
+ };
36
+
37
+ return (
38
+ <>
39
+ <UploadJobBreadcrumbs scope={scope} />
40
+ <Segment>
41
+ <Header as="h2">
42
+ <Icon
43
+ circular
44
+ name={formatMessage({
45
+ id: `uploadJobs.header.icon`,
46
+ defaultMessage: "cogs",
47
+ })}
48
+ />
49
+ <Header.Content>
50
+ <FormattedMessage id={`uploadJobs.${scope}.header`} />
51
+ <Header.Subheader>
52
+ <FormattedMessage id="uploadJobs.subheader" />
53
+ </Header.Subheader>
54
+ </Header.Content>
55
+ </Header>
56
+ <Dimmer.Dimmable dimmed={loading}>
57
+ <Segment attached="bottom">
58
+ {jobs ? (
59
+ <Table selectable>
60
+ <Table.Header>
61
+ <Table.Row>
62
+ <Table.HeaderCell>
63
+ <FormattedMessage
64
+ id={`uploadJob.table.filename`}
65
+ defaultMessage="Filename"
66
+ />
67
+ </Table.HeaderCell>
68
+ <Table.HeaderCell>
69
+ <FormattedMessage
70
+ id={`uploadJob.table.status`}
71
+ defaultMessage="Status"
72
+ />
73
+ </Table.HeaderCell>
74
+ <Table.HeaderCell>
75
+ <FormattedMessage
76
+ id={`uploadJob.table.response`}
77
+ defaultMessage="Response"
78
+ />
79
+ </Table.HeaderCell>
80
+ <Table.HeaderCell>
81
+ <FormattedMessage
82
+ id={`uploadJob.table.latest_event_at`}
83
+ defaultMessage="Latest Event"
84
+ />
85
+ </Table.HeaderCell>
86
+ </Table.Row>
87
+ </Table.Header>
88
+ <Table.Body>
89
+ {_.isArray(jobs) && !_.isEmpty(jobs) ? (
90
+ jobs.map((job, idx) => (
91
+ <Table.Row
92
+ key={idx}
93
+ style={{ cursor: "pointer" }}
94
+ onClick={() => navigateToUploadJob(job)}
95
+ >
96
+ <Table.Cell>{job.filename}</Table.Cell>
97
+ <Table.Cell>
98
+ <StatusPill status={job.latest_status} />
99
+ </Table.Cell>
100
+ <Table.Cell>
101
+ <ResponseCell
102
+ response={job.latest_event_response}
103
+ status={job.latest_status}
104
+ />
105
+ </Table.Cell>
106
+ <Table.Cell>
107
+ {job.latest_event_at
108
+ ? moment(job.latest_event_at).fromNow()
109
+ : ""}
110
+ </Table.Cell>
111
+ </Table.Row>
112
+ ))
113
+ ) : (
114
+ <Table.Row>
115
+ <Table.Cell colSpan={5}>
116
+ <FormattedMessage
117
+ id={`uploadJob.table.no_jobs`}
118
+ defaultMessage="No jobs found."
119
+ />
120
+ </Table.Cell>
121
+ </Table.Row>
122
+ )}
123
+ </Table.Body>
124
+ </Table>
125
+ ) : null}
126
+ {loading ? (
127
+ <Dimmer active inverted>
128
+ <Loader />
129
+ </Dimmer>
130
+ ) : null}
131
+ </Segment>
132
+ </Dimmer.Dimmable>
133
+ </Segment>
134
+ </>
135
+ );
136
+ }
@@ -0,0 +1,112 @@
1
+ import { render, waitForLoad } from "@truedat/test/render";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { waitFor } from "@testing-library/react";
4
+
5
+ import { useUploadJob } from "../../hooks/useUploadJobs";
6
+ import UploadJob from "../UploadJob";
7
+
8
+ jest.mock("../../hooks/useUploadJobs", () => ({
9
+ useUploadJob: jest.fn(),
10
+ }));
11
+ jest.mock("react-router", () => ({
12
+ ...jest.requireActual("react-router"),
13
+ useParams: () => ({ id: "123" }),
14
+ }));
15
+
16
+ describe("<UploadJob />", () => {
17
+ const user = userEvent.setup({ delay: null });
18
+
19
+ beforeEach(() => {
20
+ useUploadJob.mockReset();
21
+ });
22
+
23
+ it("renders loading state", async () => {
24
+ useUploadJob.mockReturnValue({
25
+ loading: true,
26
+ data: null,
27
+ });
28
+
29
+ const rendered = render(<UploadJob />);
30
+
31
+ expect(rendered.container.querySelector(".ui.loader")).toBeInTheDocument();
32
+ });
33
+
34
+ it("renders empty state when no events", async () => {
35
+ useUploadJob.mockReturnValue({
36
+ loading: false,
37
+ data: {
38
+ data: {
39
+ filename: "test.xlsx",
40
+ hash: "abc123",
41
+ latest_status: "COMPLETED",
42
+ latest_event_at: "2023-01-01T00:00:00Z",
43
+ events: [],
44
+ },
45
+ },
46
+ });
47
+
48
+ const rendered = render(<UploadJob />);
49
+ await waitForLoad(rendered);
50
+
51
+ expect(rendered.getAllByText(/test.xlsx/i)).toHaveLength(2);
52
+ expect(rendered.getByText(/abc123/i)).toBeInTheDocument();
53
+ expect(rendered.getByText(/no events found/i)).toBeInTheDocument();
54
+ });
55
+
56
+ it("renders events grouped by status", async () => {
57
+ useUploadJob.mockReturnValue({
58
+ loading: false,
59
+ data: {
60
+ data: {
61
+ filename: "test.xlsx",
62
+ hash: "abc123",
63
+ latest_status: "COMPLETED",
64
+ latest_event_at: "2023-01-01T00:00:00Z",
65
+ events: [
66
+ {
67
+ status: "INFO",
68
+ inserted_at: "2023-01-01T00:00:00Z",
69
+ response: { type: "info_message" },
70
+ },
71
+ {
72
+ status: "ERROR",
73
+ inserted_at: "2023-01-01T00:00:00Z",
74
+ response: { type: "error_message" },
75
+ },
76
+ {
77
+ status: "COMPLETED",
78
+ inserted_at: "2023-01-01T00:00:00Z",
79
+ response: { type: "completed_message" },
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ });
85
+
86
+ const rendered = render(<UploadJob />);
87
+ await waitForLoad(rendered);
88
+
89
+ // Check group headers exist
90
+ expect(rendered.getByText(/info \(1\)/i)).toBeInTheDocument();
91
+ expect(rendered.getByText(/error \(1\)/i)).toBeInTheDocument();
92
+
93
+ // Expand INFO group
94
+ await user.click(rendered.getByText(/info \(1\)/i));
95
+ await waitFor(() => {
96
+ expect(rendered.getByText(/info_message/i)).toBeInTheDocument();
97
+ });
98
+
99
+ // Expand ERROR group
100
+ await user.click(rendered.getByText(/error \(1\)/i));
101
+ await waitFor(() => {
102
+ expect(rendered.getByText(/error_message/i)).toBeInTheDocument();
103
+ });
104
+
105
+ // COMPLETED events are always visible
106
+ expect(
107
+ rendered.getAllByText(
108
+ /uploadJob.parser.event.status.COMPLETED/i
109
+ )
110
+ ).toHaveLength(2);
111
+ });
112
+ });
@@ -0,0 +1,37 @@
1
+ import { render, waitForLoad } from "@truedat/test/render";
2
+ import { UploadJobBreadcrumbs } from "../UploadJobBreadcrumbs";
3
+
4
+ describe("ImplementationUploadJobBreadcrumbs", () => {
5
+ it("renders breadcrumbs without filename", async () => {
6
+ const rendered = render(<UploadJobBreadcrumbs scope="implementations" />);
7
+ await waitForLoad(rendered);
8
+
9
+ expect(
10
+ rendered.getByText(/uploadJobs.implementations.header/i)
11
+ ).toBeInTheDocument();
12
+ expect(rendered.container).toMatchSnapshot();
13
+ });
14
+
15
+ it("renders with scope header", async () => {
16
+ const rendered = render(<UploadJobBreadcrumbs scope="myscope" />);
17
+ await waitForLoad(rendered);
18
+
19
+ expect(
20
+ rendered.getByText(/uploadJobs.myscope.header/i)
21
+ ).toBeInTheDocument();
22
+ });
23
+
24
+ it("renders breadcrumbs with filename", async () => {
25
+ const filename = "test.csv";
26
+ const rendered = render(
27
+ <UploadJobBreadcrumbs scope="implementations" filename={filename} />
28
+ );
29
+ await waitForLoad(rendered);
30
+
31
+ expect(
32
+ rendered.getByText(/uploadJobs.implementations.header/i)
33
+ ).toBeInTheDocument();
34
+ expect(rendered.getByText(filename)).toBeInTheDocument();
35
+ expect(rendered.container).toMatchSnapshot();
36
+ });
37
+ });
@@ -0,0 +1,103 @@
1
+ import { render } from "@truedat/test/render";
2
+ import { StatusPill, ResponseCell } from "../UploadJobParser";
3
+
4
+ describe("<StatusPill />", () => {
5
+ it("renders with correct color based on status", async () => {
6
+ const statuses = ["COMPLETED", "FAILED", "ERROR", "INFO", "OTHER"];
7
+ const expectedColors = ["green", "red", "red", "blue", "grey"];
8
+
9
+ statuses.forEach((status, index) => {
10
+ const rendered = render(<StatusPill status={status} />);
11
+ const label = rendered.container.querySelector(".ui.label");
12
+ expect(label).toHaveClass(expectedColors[index]);
13
+ expect(
14
+ rendered.getByText(
15
+ new RegExp(`uploadJob.parser.event.status.${status}`, "i")
16
+ )
17
+ ).toBeInTheDocument();
18
+ });
19
+ });
20
+ });
21
+
22
+ describe("<ResponseCell />", () => {
23
+ it("returns null when response is empty", () => {
24
+ const rendered = render(
25
+ <ResponseCell response={null} status="COMPLETED" />
26
+ );
27
+ expect(rendered.container.firstChild).toBeNull();
28
+
29
+ const rendered2 = render(<ResponseCell response={{}} status="COMPLETED" />);
30
+ expect(rendered2.container.firstChild).toBeNull();
31
+ });
32
+
33
+ it("renders FAILED status with message", () => {
34
+ const response = { message: "missing_required_headers" };
35
+ const rendered = render(
36
+ <ResponseCell response={response} status="FAILED" />
37
+ );
38
+ expect(
39
+ rendered.getByText(/missing_required_headers/i)
40
+ ).toBeInTheDocument();
41
+ });
42
+
43
+ it("renders ERROR status with details", () => {
44
+ const response = {
45
+ type: "missing_required_headers",
46
+ details: {
47
+ missing_headers: ["header1", "header2"],
48
+ },
49
+ };
50
+ const rendered = render(
51
+ <ResponseCell response={response} status="ERROR" />
52
+ );
53
+ expect(
54
+ rendered.getByText(
55
+ /uploadJob.parser.error.missing_required_headers/i
56
+ )
57
+ ).toBeInTheDocument();
58
+ expect(rendered.getByText(/header1, header2/i)).toBeInTheDocument();
59
+ });
60
+
61
+ it("renders COMPLETED status with summary", () => {
62
+ const response = {
63
+ insert_count: 2,
64
+ update_count: 1,
65
+ error_count: 0,
66
+ };
67
+ const rendered = render(
68
+ <ResponseCell response={response} status="COMPLETED" />
69
+ );
70
+ expect(
71
+ rendered.getByText(/uploadJob.parser.result.summary.created/i)
72
+ ).toBeInTheDocument();
73
+ expect(
74
+ rendered.getByText(/uploadJob.parser.result.summary.updated/i)
75
+ ).toBeInTheDocument();
76
+ });
77
+
78
+ it("renders INFO status with implementation details", () => {
79
+ const response = {
80
+ type: "implementation_updated",
81
+ details: {
82
+ id: 123,
83
+ changes: {
84
+ name: "new name",
85
+ },
86
+ },
87
+ };
88
+ const rendered = render(<ResponseCell response={response} status="INFO" />);
89
+ expect(
90
+ rendered.getByText(
91
+ /uploadJob.parser.info.implementation_updated/i
92
+ )
93
+ ).toBeInTheDocument();
94
+ });
95
+
96
+ it("renders default case for unknown status", () => {
97
+ const response = "some text";
98
+ const rendered = render(
99
+ <ResponseCell response={response} status="UNKNOWN" />
100
+ );
101
+ expect(rendered.getByText(/some text/i)).toBeInTheDocument();
102
+ });
103
+ });
@@ -0,0 +1,60 @@
1
+ import { render, waitForLoad } from "@truedat/test/render";
2
+ import { useUploadJobs } from "../../hooks/useUploadJobs";
3
+ import UploadJobs from "../UploadJobs";
4
+
5
+ jest.mock("../../hooks/useUploadJobs", () => ({
6
+ useUploadJobs: jest.fn(),
7
+ }));
8
+
9
+ describe("<UploadJobs />", () => {
10
+ it("renders loading state", async () => {
11
+ useUploadJobs.mockReturnValue({
12
+ loading: true,
13
+ data: null,
14
+ });
15
+
16
+ const rendered = render(<UploadJobs />);
17
+
18
+ expect(rendered.container.querySelector(".ui.loader")).toBeInTheDocument();
19
+ });
20
+
21
+ it("renders empty state", async () => {
22
+ useUploadJobs.mockReturnValue({
23
+ loading: false,
24
+ data: {
25
+ data: [],
26
+ },
27
+ });
28
+
29
+ const rendered = render(<UploadJobs />);
30
+ await waitForLoad(rendered);
31
+
32
+ expect(rendered.getByText(/No jobs found./i)).toBeInTheDocument();
33
+ });
34
+
35
+ it("renders jobs list", async () => {
36
+ const mockJobs = [
37
+ {
38
+ id: 1,
39
+ filename: "test.csv",
40
+ latest_status: "completed",
41
+ latest_event_response: "Success",
42
+ latest_event_at: "2023-01-01T00:00:00Z",
43
+ },
44
+ ];
45
+
46
+ useUploadJobs.mockReturnValue({
47
+ loading: false,
48
+ data: {
49
+ data: mockJobs,
50
+ },
51
+ });
52
+
53
+ const rendered = render(<UploadJobs />);
54
+ await waitForLoad(rendered);
55
+
56
+ expect(rendered.getByText(/test.csv/i)).toBeInTheDocument();
57
+ expect(rendered.getByText(/completed/i)).toBeInTheDocument();
58
+ expect(rendered.getByText(/success/i)).toBeInTheDocument();
59
+ });
60
+ });
@@ -56,7 +56,7 @@ exports[`<CatalogMenu /> matches the latest snapshot 1`] = `
56
56
  <a
57
57
  class="link item"
58
58
  data-discover="true"
59
- href="/bulkUpdateTemplateContentEvents"
59
+ href="/structureNotes/uploadJobs"
60
60
  >
61
61
  structures_upload_events
62
62
  </a>
@@ -192,7 +192,7 @@ exports[`<SideMenu /> matches the latest snapshot 1`] = `
192
192
  <a
193
193
  class="link item"
194
194
  data-discover="true"
195
- href="/bulkUpdateTemplateContentEvents"
195
+ href="/structureNotes/uploadJobs"
196
196
  >
197
197
  structures_upload_events
198
198
  </a>
@@ -0,0 +1,42 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`ImplementationUploadJobBreadcrumbs renders breadcrumbs with filename 1`] = `
4
+ <div>
5
+ <div
6
+ class="ui breadcrumb"
7
+ >
8
+ <a
9
+ class="section"
10
+ data-discover="true"
11
+ href="/implementations/uploadJobs"
12
+ >
13
+ uploadJobs.implementations.header
14
+ </a>
15
+ <i
16
+ aria-hidden="true"
17
+ class="right angle icon divider"
18
+ />
19
+ <div
20
+ class="active section"
21
+ >
22
+ test.csv
23
+ </div>
24
+ </div>
25
+ </div>
26
+ `;
27
+
28
+ exports[`ImplementationUploadJobBreadcrumbs renders breadcrumbs without filename 1`] = `
29
+ <div>
30
+ <div
31
+ class="ui breadcrumb"
32
+ >
33
+ <a
34
+ class="section"
35
+ data-discover="true"
36
+ href="/implementations/uploadJobs"
37
+ >
38
+ uploadJobs.implementations.header
39
+ </a>
40
+ </div>
41
+ </div>
42
+ `;
@@ -7,3 +7,4 @@ export * from "./usePath";
7
7
  export * from "./useOnScreen";
8
8
  export * from "./useOperators";
9
9
  export * from "./useTemplate";
10
+ export * from "./useUploadJobs";
@@ -0,0 +1,24 @@
1
+ import { compile } from "path-to-regexp";
2
+ import useSWR from "swr";
3
+ import { apiJson } from "@truedat/core/services/api";
4
+ import {
5
+ API_UPLOAD_JOBS,
6
+ API_UPLOAD_JOB,
7
+ } from "../api";
8
+
9
+ export const useUploadJobs = (scope) => {
10
+ const url = scope ? `${API_UPLOAD_JOBS}?scope=${scope}` : API_UPLOAD_JOBS;
11
+ const { data, error, mutate } = useSWR(
12
+ url,
13
+ apiJson
14
+ );
15
+ return { data: data?.data, error, loading: !error && !data, mutate };
16
+ };
17
+
18
+ export const useUploadJob = (id) => {
19
+ const url = compile(API_UPLOAD_JOB)({
20
+ id: `${id}`,
21
+ });
22
+ const { data, error, mutate } = useSWR(url, apiJson);
23
+ return { data: data?.data, error, loading: !error && !data, mutate };
24
+ };
package/src/routes.js CHANGED
@@ -234,7 +234,6 @@ export const SOURCE_JOBS_NEW = "/sources/:sourceId/jobs/new";
234
234
  export const STRUCTURE = "/structures/:id";
235
235
  export const STRUCTURES = "/structures";
236
236
  export const STRUCTURES_BULK_UPDATE = "/structures/bulkUpdate";
237
- export const STRUCTURES_UPLOAD_EVENTS = "/bulkUpdateTemplateContentEvents";
238
237
  export const STRUCTURE_CHILDREN = "/structures/:id/children";
239
238
  export const STRUCTURE_FIELDS = "/structures/:id/fields";
240
239
  export const STRUCTURE_VERSION_FIELDS =
@@ -254,6 +253,8 @@ export const STRUCTURE_MEMBERS_NEW = "/structures/:id/members/new";
254
253
  export const STRUCTURE_METADATA = "/structures/:id/metadata";
255
254
  export const STRUCTURE_NOTES = "/structures/:id/notes";
256
255
  export const STRUCTURE_NOTES_EDIT = "/structures/:id/notes/edit";
256
+ export const STRUCTURE_NOTES_UPLOAD_JOBS = "/structureNotes/uploadJobs";
257
+ export const STRUCTURE_NOTES_UPLOAD_JOB = "/structureNotes/uploadJobs/:id";
257
258
  export const STRUCTURE_PARENTS = "/structures/:id/parents";
258
259
  export const STRUCTURE_PROFILE = "/structures/:id/profile";
259
260
  export const STRUCTURE_RULES = "/structures/:id/rules";
@@ -473,7 +474,6 @@ const routes = {
473
474
  STRUCTURE,
474
475
  STRUCTURES,
475
476
  STRUCTURES_BULK_UPDATE,
476
- STRUCTURES_UPLOAD_EVENTS,
477
477
  STRUCTURE_CHILDREN,
478
478
  STRUCTURE_FIELDS,
479
479
  STRUCTURE_VERSION_FIELDS,
@@ -491,6 +491,8 @@ const routes = {
491
491
  STRUCTURE_METADATA,
492
492
  STRUCTURE_NOTES_EDIT,
493
493
  STRUCTURE_NOTES,
494
+ STRUCTURE_NOTES_UPLOAD_JOBS,
495
+ STRUCTURE_NOTES_UPLOAD_JOB,
494
496
  STRUCTURE_PARENTS,
495
497
  STRUCTURE_PROFILE,
496
498
  STRUCTURE_RULES,