@truedat/core 8.13.0 → 8.13.1

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.
@@ -0,0 +1,278 @@
1
+ import { useId, useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { Icon, Label, Table } from "semantic-ui-react";
5
+ import DateTime from "@truedat/core/components/DateTime";
6
+ import { ResultDetailCell, ResultEntityCell } from "./UploadJobParser";
7
+ import { getUploadJobScope } from "./UploadJobScope";
8
+
9
+ const filters = ["all", "created", "updated", "unchanged", "error"];
10
+
11
+ const filterMessages = {
12
+ all: { id: "uploadJob.results.filter.all" },
13
+ created: { id: "uploadJob.results.filter.created" },
14
+ updated: { id: "uploadJob.results.filter.updated" },
15
+ unchanged: { id: "uploadJob.results.filter.unchanged" },
16
+ error: { id: "uploadJob.results.filter.error" },
17
+ };
18
+
19
+ export const getResultEvents = (events = []) =>
20
+ events.filter(({ status }) => status === "INFO" || status === "ERROR");
21
+
22
+ export const getResultOutcome = ({ status, response }) => {
23
+ if (status === "ERROR") return "error";
24
+ if (response?.type === "unchanged") return "unchanged";
25
+ if (response?.type === "created") return "created";
26
+ if (response?.type === "updated") return "updated";
27
+ return "unknown";
28
+ };
29
+
30
+ const Outcome = ({ event }) => {
31
+ const outcome = getResultOutcome(event);
32
+ const config = {
33
+ created: {
34
+ className: "upload-job-outcome-created",
35
+ id: "uploadJob.parser.info.created",
36
+ },
37
+ updated: {
38
+ className: "upload-job-outcome-updated",
39
+ id: "uploadJob.parser.info.updated",
40
+ },
41
+ unchanged: {
42
+ className: "upload-job-outcome-unchanged",
43
+ color: "grey",
44
+ id: "uploadJob.parser.info.unchanged",
45
+ },
46
+ error: {
47
+ className: "upload-job-outcome-error",
48
+ color: "red",
49
+ id: "uploadJob.parser.event.status.ERROR",
50
+ },
51
+ unknown: {
52
+ className: "upload-job-outcome-unknown",
53
+ color: "grey",
54
+ id: "uploadJob.parser.event.status.INFO",
55
+ },
56
+ }[outcome];
57
+
58
+ return (
59
+ <Label
60
+ color={config.color}
61
+ className={`upload-job-outcome ${config.className}`}
62
+ size="small"
63
+ >
64
+ <FormattedMessage id={config.id} />
65
+ </Label>
66
+ );
67
+ };
68
+
69
+ Outcome.propTypes = {
70
+ event: PropTypes.shape({
71
+ status: PropTypes.string,
72
+ response: PropTypes.shape({ type: PropTypes.string }),
73
+ }).isRequired,
74
+ };
75
+
76
+ const Origin = ({ response }) => {
77
+ const { formatMessage } = useIntl();
78
+ const sheet = response?.sheet;
79
+ const rowNumber = response?.row_number;
80
+
81
+ if (!sheet && !rowNumber) return null;
82
+
83
+ const origin = [
84
+ sheet,
85
+ rowNumber
86
+ ? formatMessage({ id: "uploadJob.results.origin.row" }, { rowNumber })
87
+ : null,
88
+ ]
89
+ .filter(Boolean)
90
+ .join(" · ");
91
+
92
+ return (
93
+ <Label size="small" className="upload-job-origin" title={origin}>
94
+ {origin}
95
+ </Label>
96
+ );
97
+ };
98
+
99
+ Origin.propTypes = {
100
+ response: PropTypes.shape({
101
+ row_number: PropTypes.number,
102
+ sheet: PropTypes.string,
103
+ }),
104
+ };
105
+
106
+ Origin.defaultProps = { response: undefined };
107
+
108
+ export const getEntityColumnMessageId = (scope) =>
109
+ getUploadJobScope(scope).entityColumnMessageId;
110
+
111
+ const UploadJobResults = ({
112
+ constrainResults,
113
+ events,
114
+ lifecycleStatus,
115
+ scope,
116
+ }) => {
117
+ const { formatMessage } = useIntl();
118
+ const [expanded, setExpanded] = useState(false);
119
+ const [filter, setFilter] = useState("all");
120
+ const contentId = useId();
121
+ const enabled = lifecycleStatus === "COMPLETED";
122
+ const resultsTitleId = useId();
123
+ const resultEvents = getResultEvents(events);
124
+ const visibleEvents = resultEvents.filter(
125
+ (event) => filter === "all" || getResultOutcome(event) === filter,
126
+ );
127
+
128
+ const toggleExpanded = () => {
129
+ if (enabled) setExpanded((value) => !value);
130
+ };
131
+
132
+ return (
133
+ <section
134
+ className={`upload-job-results${enabled ? "" : " disabled"}`}
135
+ aria-labelledby={resultsTitleId}
136
+ >
137
+ <div className="upload-job-results-heading">
138
+ <button
139
+ type="button"
140
+ className="upload-job-results-toggle"
141
+ aria-expanded={expanded}
142
+ aria-controls={contentId}
143
+ disabled={!enabled}
144
+ onClick={toggleExpanded}
145
+ >
146
+ <span>
147
+ <strong id={resultsTitleId}>
148
+ <FormattedMessage id="uploadJob.results.title" />
149
+ </strong>
150
+ <small>
151
+ <FormattedMessage id="uploadJob.results.subtitle" />
152
+ </small>
153
+ </span>
154
+ <Icon
155
+ aria-hidden="true"
156
+ className="upload-job-results-chevron"
157
+ name={expanded ? "chevron up" : "chevron down"}
158
+ />
159
+ </button>
160
+ {expanded ? (
161
+ <div
162
+ className="upload-job-result-filters"
163
+ role="group"
164
+ aria-label={formatMessage({
165
+ id: "uploadJob.results.filter.label",
166
+ })}
167
+ >
168
+ {filters.map((name) => (
169
+ <button
170
+ key={name}
171
+ type="button"
172
+ className={`ui small button upload-job-result-filter upload-job-result-filter-${name}${
173
+ filter === name ? " active" : ""
174
+ }`}
175
+ aria-pressed={filter === name}
176
+ onClick={() => setFilter(name)}
177
+ >
178
+ <FormattedMessage {...filterMessages[name]} />
179
+ </button>
180
+ ))}
181
+ </div>
182
+ ) : null}
183
+ </div>
184
+
185
+ <div
186
+ id={contentId}
187
+ className="upload-job-results-content"
188
+ hidden={!expanded}
189
+ >
190
+ {expanded ? (
191
+ <div
192
+ className={`upload-job-table-wrap${
193
+ constrainResults ? " upload-job-table-wrap-constrained" : ""
194
+ }`}
195
+ style={
196
+ constrainResults
197
+ ? { maxHeight: "48vh", overflow: "auto" }
198
+ : undefined
199
+ }
200
+ >
201
+ <Table>
202
+ <Table.Header>
203
+ <Table.Row>
204
+ <Table.HeaderCell>
205
+ <FormattedMessage id="uploadJob.results.column.outcome" />
206
+ </Table.HeaderCell>
207
+ <Table.HeaderCell>
208
+ <FormattedMessage id="uploadJob.results.column.processedAt" />
209
+ </Table.HeaderCell>
210
+ <Table.HeaderCell>
211
+ <FormattedMessage id={getEntityColumnMessageId(scope)} />
212
+ </Table.HeaderCell>
213
+ <Table.HeaderCell>
214
+ <FormattedMessage id="uploadJob.table.detail" />
215
+ </Table.HeaderCell>
216
+ <Table.HeaderCell>
217
+ <FormattedMessage id="uploadJob.results.column.origin" />
218
+ </Table.HeaderCell>
219
+ </Table.Row>
220
+ </Table.Header>
221
+ <Table.Body>
222
+ {visibleEvents.map((event, index) => (
223
+ <Table.Row key={event.id ?? `${event.status}-${index}`}>
224
+ <Table.Cell className="upload-job-result-outcome-cell">
225
+ <Outcome event={event} />
226
+ </Table.Cell>
227
+ <Table.Cell className="upload-job-result-processed-cell">
228
+ <DateTime value={event.inserted_at} />
229
+ </Table.Cell>
230
+ <Table.Cell className="upload-job-result-entity-cell">
231
+ <ResultEntityCell {...event} />
232
+ </Table.Cell>
233
+ <Table.Cell className="upload-job-result-detail-cell">
234
+ <ResultDetailCell {...event} scope={scope} />
235
+ </Table.Cell>
236
+ <Table.Cell className="upload-job-result-origin-cell">
237
+ <Origin response={event.response} />
238
+ </Table.Cell>
239
+ </Table.Row>
240
+ ))}
241
+ {visibleEvents.length === 0 ? (
242
+ <Table.Row>
243
+ <Table.Cell colSpan={5} textAlign="center">
244
+ <FormattedMessage id="uploadJob.results.empty" />
245
+ </Table.Cell>
246
+ </Table.Row>
247
+ ) : null}
248
+ </Table.Body>
249
+ </Table>
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ </section>
254
+ );
255
+ };
256
+
257
+ UploadJobResults.propTypes = {
258
+ constrainResults: PropTypes.bool,
259
+ events: PropTypes.arrayOf(
260
+ PropTypes.shape({
261
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
262
+ inserted_at: PropTypes.string,
263
+ response: PropTypes.object,
264
+ status: PropTypes.string,
265
+ }),
266
+ ),
267
+ lifecycleStatus: PropTypes.string,
268
+ scope: PropTypes.string,
269
+ };
270
+
271
+ UploadJobResults.defaultProps = {
272
+ constrainResults: false,
273
+ events: [],
274
+ lifecycleStatus: undefined,
275
+ scope: "implementations",
276
+ };
277
+
278
+ export default UploadJobResults;
@@ -0,0 +1,37 @@
1
+ import {
2
+ IMPLEMENTATIONS_UPLOAD_JOBS,
3
+ STRUCTURE_NOTES_UPLOAD_JOBS,
4
+ linkTo,
5
+ } from "@truedat/core/routes";
6
+
7
+ export const uploadJobScopes = {
8
+ implementations: {
9
+ listPath: IMPLEMENTATIONS_UPLOAD_JOBS,
10
+ detailPath: (id) => linkTo.IMPLEMENTATIONS_UPLOAD_JOB({ id }),
11
+ listHeaderId: "uploadJobs.implementations.header",
12
+ detailHeaderId: "uploadJob.implementations.header",
13
+ entityColumnMessageId: "uploadJob.results.column.implementation",
14
+ fieldMessagePrefix: "ruleImplementations.props",
15
+ },
16
+ notes: {
17
+ listPath: STRUCTURE_NOTES_UPLOAD_JOBS,
18
+ detailPath: (id) => linkTo.STRUCTURE_NOTES_UPLOAD_JOB({ id }),
19
+ listHeaderId: "uploadJobs.notes.header",
20
+ detailHeaderId: "uploadJob.notes.header",
21
+ entityColumnMessageId: "uploadJob.results.column.structure",
22
+ fieldMessagePrefix: "structures.props",
23
+ fieldMessageOverrides: { status: "structure.notes.status" },
24
+ },
25
+ };
26
+
27
+ const unknownScope = (scope) => ({
28
+ listPath: null,
29
+ detailPath: null,
30
+ listHeaderId: `uploadJobs.${scope}.header`,
31
+ detailHeaderId: `uploadJob.${scope}.header`,
32
+ entityColumnMessageId: uploadJobScopes.implementations.entityColumnMessageId,
33
+ fieldMessagePrefix: uploadJobScopes.implementations.fieldMessagePrefix,
34
+ });
35
+
36
+ export const getUploadJobScope = (scope) =>
37
+ uploadJobScopes[scope] ?? unknownScope(scope);
@@ -0,0 +1,211 @@
1
+ import _ from "lodash/fp";
2
+ import PropTypes from "prop-types";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { Message } from "semantic-ui-react";
5
+
6
+ export const lifecycleStatuses = ["PENDING", "STARTED", "COMPLETED", "FAILED"];
7
+
8
+ const messagePropsByStatus = {
9
+ PENDING: { warning: true },
10
+ STARTED: { info: true },
11
+ COMPLETED: { positive: true },
12
+ FAILED: { negative: true },
13
+ };
14
+
15
+ const emptySummary = {
16
+ insert_count: 0,
17
+ update_count: 0,
18
+ unchanged_count: 0,
19
+ error_count: 0,
20
+ };
21
+
22
+ const resultTypeCounters = {
23
+ created: "insert_count",
24
+ updated: "update_count",
25
+ unchanged: "unchanged_count",
26
+ };
27
+
28
+ const summaryCounterKeys = [
29
+ "insert_count",
30
+ "update_count",
31
+ "unchanged_count",
32
+ "error_count",
33
+ "invalid_sheet_count",
34
+ ];
35
+
36
+ const hasSummaryCounters = (response) =>
37
+ response && summaryCounterKeys.some((key) => key in response);
38
+
39
+ export const getUploadJobStatus = (events = [], latestStatus) =>
40
+ _.flow(
41
+ _.filter(({ status }) => lifecycleStatuses.includes(status)),
42
+ _.last,
43
+ _.getOr(latestStatus, "status"),
44
+ )(events);
45
+
46
+ export const classifyUploadJobErrorMessage = (message) => {
47
+ if (typeof message !== "string" || message.trim() === "") {
48
+ return { type: "unknown" };
49
+ }
50
+
51
+ const normalizedMessage = message.trim();
52
+ const isTechnicalError =
53
+ /^%[A-Z][A-Za-z0-9_.]*\{/.test(normalizedMessage) ||
54
+ /^\{:/.test(normalizedMessage) ||
55
+ /^\*\* \(/.test(normalizedMessage);
56
+
57
+ return isTechnicalError
58
+ ? { type: "unexpected_error" }
59
+ : { type: "message", message: normalizedMessage };
60
+ };
61
+
62
+ export const getUploadJobSummary = (events = [], latestResponse) => {
63
+ const completedResponse = _.flow(
64
+ _.filter(({ status }) => status === "COMPLETED"),
65
+ _.last,
66
+ _.get("response"),
67
+ )(events);
68
+ const response = hasSummaryCounters(completedResponse)
69
+ ? completedResponse
70
+ : latestResponse;
71
+
72
+ if (hasSummaryCounters(response)) {
73
+ return {
74
+ insert_count: response.insert_count ?? 0,
75
+ update_count: response.update_count ?? 0,
76
+ unchanged_count: response.unchanged_count ?? 0,
77
+ error_count:
78
+ (response.error_count ?? 0) + (response.invalid_sheet_count ?? 0),
79
+ };
80
+ }
81
+
82
+ return events.reduce((summary, { status, response: eventResponse }) => {
83
+ if (status === "ERROR") {
84
+ return { ...summary, error_count: summary.error_count + 1 };
85
+ }
86
+
87
+ const counter =
88
+ status === "INFO" && resultTypeCounters[eventResponse?.type];
89
+ return counter ? { ...summary, [counter]: summary[counter] + 1 } : summary;
90
+ }, emptySummary);
91
+ };
92
+
93
+ const SummaryItem = ({ messageId, value }) => (
94
+ <span>
95
+ <FormattedMessage id={messageId} />: <strong>{value}</strong>
96
+ </span>
97
+ );
98
+
99
+ SummaryItem.propTypes = {
100
+ messageId: PropTypes.string.isRequired,
101
+ value: PropTypes.number.isRequired,
102
+ };
103
+
104
+ const CompletedSummary = ({ summary }) => {
105
+ const total = Object.values(summary).reduce((sum, value) => sum + value, 0);
106
+
107
+ return (
108
+ <>
109
+ <strong>
110
+ <FormattedMessage id="uploadJob.summary.title" />
111
+ </strong>
112
+ <SummaryItem messageId="uploadJob.summary.total" value={total} />
113
+ <SummaryItem
114
+ messageId="uploadJob.parser.result.summary.created"
115
+ value={summary.insert_count}
116
+ />
117
+ <SummaryItem
118
+ messageId="uploadJob.parser.result.summary.updated"
119
+ value={summary.update_count}
120
+ />
121
+ <SummaryItem
122
+ messageId="uploadJob.parser.result.summary.unchanged"
123
+ value={summary.unchanged_count}
124
+ />
125
+ <SummaryItem
126
+ messageId="uploadJob.parser.result.summary.errors"
127
+ value={summary.error_count}
128
+ />
129
+ </>
130
+ );
131
+ };
132
+
133
+ CompletedSummary.propTypes = {
134
+ summary: PropTypes.shape({
135
+ insert_count: PropTypes.number.isRequired,
136
+ update_count: PropTypes.number.isRequired,
137
+ unchanged_count: PropTypes.number.isRequired,
138
+ error_count: PropTypes.number.isRequired,
139
+ }).isRequired,
140
+ };
141
+
142
+ export const UploadJobSummary = ({ events, latestStatus, latestResponse }) => {
143
+ const { formatMessage } = useIntl();
144
+ const status = getUploadJobStatus(events, latestStatus);
145
+ const summary = getUploadJobSummary(events, latestResponse);
146
+ const eventFailedResponse = _.flow(
147
+ _.filter(({ status: eventStatus }) => eventStatus === "FAILED"),
148
+ _.last,
149
+ _.get("response"),
150
+ )(events);
151
+ const failedResponse = eventFailedResponse?.message
152
+ ? eventFailedResponse
153
+ : latestResponse;
154
+ const error = classifyUploadJobErrorMessage(failedResponse?.message);
155
+ const detail =
156
+ error.type === "message"
157
+ ? formatMessage({
158
+ id: `uploadJob.parser.error.${error.message}`,
159
+ defaultMessage: error.message,
160
+ })
161
+ : formatMessage({ id: `uploadJob.parser.error.${error.type}` });
162
+
163
+ return (
164
+ <Message
165
+ {...messagePropsByStatus[status]}
166
+ aria-label={formatMessage({ id: "uploadJob.summary.title" })}
167
+ className="upload-job-summary"
168
+ >
169
+ <div className="upload-job-summary-content">
170
+ {status === "COMPLETED" ? <CompletedSummary summary={summary} /> : null}
171
+ {status === "FAILED" ? (
172
+ <strong>
173
+ <FormattedMessage
174
+ id="uploadJob.summary.failed"
175
+ values={{ detail }}
176
+ />
177
+ </strong>
178
+ ) : null}
179
+ {status === "PENDING" || status === "STARTED" ? (
180
+ <>
181
+ <strong>
182
+ <FormattedMessage id="uploadJob.summary.title" />
183
+ </strong>
184
+ <FormattedMessage
185
+ id={`uploadJob.summary.${status.toLowerCase()}`}
186
+ />
187
+ </>
188
+ ) : null}
189
+ </div>
190
+ </Message>
191
+ );
192
+ };
193
+
194
+ UploadJobSummary.propTypes = {
195
+ events: PropTypes.arrayOf(
196
+ PropTypes.shape({
197
+ status: PropTypes.string,
198
+ response: PropTypes.object,
199
+ }),
200
+ ),
201
+ latestResponse: PropTypes.object,
202
+ latestStatus: PropTypes.string,
203
+ };
204
+
205
+ UploadJobSummary.defaultProps = {
206
+ events: [],
207
+ latestResponse: undefined,
208
+ latestStatus: undefined,
209
+ };
210
+
211
+ export default UploadJobSummary;
@@ -1,7 +1,8 @@
1
1
  import _ from "lodash/fp";
2
2
  import moment from "moment";
3
+ import PropTypes from "prop-types";
4
+ import { useState } from "react";
3
5
  import { FormattedMessage, useIntl } from "react-intl";
4
- import { useNavigate } from "react-router";
5
6
  import {
6
7
  Header,
7
8
  Icon,
@@ -10,27 +11,27 @@ import {
10
11
  Loader,
11
12
  Table,
12
13
  } from "semantic-ui-react";
13
- import { linkTo } from "@truedat/core/routes";
14
14
  import { useUploadJobs } from "../hooks/useUploadJobs";
15
+ import { TrueModal } from "./TrueModal";
15
16
  import { StatusPill, ResponseCell } from "./UploadJobParser";
16
17
  import UploadJobBreadcrumbs from "./UploadJobBreadcrumbs";
18
+ import { UploadJobModalContent } from "./UploadJob";
19
+ import { getUploadJobScope } from "./UploadJobScope";
17
20
 
18
21
  export default function UploadJobs({ scope }) {
19
22
  const { formatMessage } = useIntl();
20
23
  const { data, loading } = useUploadJobs(scope);
21
24
  const jobs = data?.data;
22
- const navigate = useNavigate();
25
+ const [selectedJobId, setSelectedJobId] = useState(null);
26
+ const scopeConfig = getUploadJobScope(scope);
23
27
 
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;
28
+ const openUploadJob = ({ id }) => setSelectedJobId(id);
29
+ const closeUploadJob = () => setSelectedJobId(null);
30
+
31
+ const handleRowKeyDown = (job) => (e) => {
32
+ if (e.key === "Enter" || e.key === " ") {
33
+ e.preventDefault();
34
+ openUploadJob(job);
34
35
  }
35
36
  };
36
37
 
@@ -47,7 +48,7 @@ export default function UploadJobs({ scope }) {
47
48
  })}
48
49
  />
49
50
  <Header.Content>
50
- <FormattedMessage id={`uploadJobs.${scope}.header`} />
51
+ <FormattedMessage id={scopeConfig.listHeaderId} />
51
52
  <Header.Subheader>
52
53
  <FormattedMessage id="uploadJobs.subheader" />
53
54
  </Header.Subheader>
@@ -87,11 +88,14 @@ export default function UploadJobs({ scope }) {
87
88
  </Table.Header>
88
89
  <Table.Body>
89
90
  {_.isArray(jobs) && !_.isEmpty(jobs) ? (
90
- jobs.map((job, idx) => (
91
+ jobs.map((job) => (
91
92
  <Table.Row
92
- key={idx}
93
+ key={job.id}
93
94
  style={{ cursor: "pointer" }}
94
- onClick={() => navigateToUploadJob(job)}
95
+ tabIndex={0}
96
+ aria-label={job.filename}
97
+ onClick={() => openUploadJob(job)}
98
+ onKeyDown={handleRowKeyDown(job)}
95
99
  >
96
100
  <Table.Cell>{job.filename}</Table.Cell>
97
101
  <Table.Cell>
@@ -131,6 +135,33 @@ export default function UploadJobs({ scope }) {
131
135
  </Segment>
132
136
  </Dimmer.Dimmable>
133
137
  </Segment>
138
+ <TrueModal
139
+ open={selectedJobId != null}
140
+ onClose={closeUploadJob}
141
+ size="large"
142
+ className="upload-job-modal"
143
+ icon={formatMessage({
144
+ id: "uploadJob.header.icon",
145
+ defaultMessage: "cogs",
146
+ })}
147
+ title={formatMessage({
148
+ id: scopeConfig.detailHeaderId,
149
+ defaultMessage: "Upload detail",
150
+ })}
151
+ actions={[]}
152
+ >
153
+ {selectedJobId != null ? (
154
+ <UploadJobModalContent id={selectedJobId} scope={scope} />
155
+ ) : null}
156
+ </TrueModal>
134
157
  </>
135
158
  );
136
159
  }
160
+
161
+ UploadJobs.propTypes = {
162
+ scope: PropTypes.string,
163
+ };
164
+
165
+ UploadJobs.defaultProps = {
166
+ scope: "implementations",
167
+ };