@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 +3 -3
- package/src/api.js +2 -0
- package/src/components/CatalogMenu.js +2 -2
- package/src/components/UploadJob.js +217 -0
- package/src/components/UploadJobBreadcrumbs.js +38 -0
- package/src/components/UploadJobParser.js +370 -0
- package/src/components/UploadJobs.js +136 -0
- package/src/components/__tests__/UploadJob.spec.js +112 -0
- package/src/components/__tests__/UploadJobBreadcrumbs.spec.js +37 -0
- package/src/components/__tests__/UploadJobParser.spec.js +103 -0
- package/src/components/__tests__/UploadJobs.spec.js +60 -0
- package/src/components/__tests__/__snapshots__/CatalogMenu.spec.js.snap +1 -1
- package/src/components/__tests__/__snapshots__/SideMenu.spec.js.snap +1 -1
- package/src/components/__tests__/__snapshots__/UploadJobBreadcrumbs.spec.js.snap +42 -0
- package/src/hooks/index.js +1 -0
- package/src/hooks/useUploadJobs.js +24 -0
- package/src/routes.js +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/core",
|
|
3
|
-
"version": "8.1.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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: [
|
|
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> | </span>}
|
|
255
|
+
</span>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
])(summaryItems);
|
|
261
|
+
|
|
262
|
+
if (items.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<span> | {items} | </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
|
+
});
|
|
@@ -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
|
+
`;
|
package/src/hooks/index.js
CHANGED
|
@@ -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,
|