@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/core",
3
- "version": "8.13.0",
3
+ "version": "8.13.1",
4
4
  "description": "Truedat Web Core",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -57,7 +57,7 @@
57
57
  "@testing-library/jest-dom": "^6.6.3",
58
58
  "@testing-library/react": "^16.3.0",
59
59
  "@testing-library/user-event": "^14.6.1",
60
- "@truedat/test": "8.13.0",
60
+ "@truedat/test": "8.13.1",
61
61
  "identity-obj-proxy": "^3.0.0",
62
62
  "jest": "^29.7.0",
63
63
  "redux-saga-test-plan": "^4.0.6"
@@ -99,5 +99,5 @@
99
99
  "swr": "^2.3.3",
100
100
  "turndown": "^7.2.2"
101
101
  },
102
- "gitHead": "b9d1c2133cea6293e6a5ce5d198a4cd49133b4c1"
102
+ "gitHead": "09ce6d3a8693835de75577e43d99d86addb2edf7"
103
103
  }
@@ -1,113 +1,119 @@
1
- import _ from "lodash/fp";
2
- import { useState } from "react";
1
+ import PropTypes from "prop-types";
3
2
  import { FormattedMessage, useIntl } from "react-intl";
4
3
  import { useParams } from "react-router";
5
4
  import {
5
+ Dimmer,
6
6
  Header,
7
7
  Icon,
8
- Segment,
9
- Dimmer,
10
- Loader,
11
- Table,
12
8
  Label,
13
- Button,
9
+ Loader,
10
+ Segment,
14
11
  } from "semantic-ui-react";
15
12
  import DateTime from "@truedat/core/components/DateTime";
16
13
  import { useUploadJob } from "../hooks/useUploadJobs";
17
- import { StatusPill, ResponseCell } from "./UploadJobParser";
18
14
  import UploadJobBreadcrumbs from "./UploadJobBreadcrumbs";
15
+ import UploadJobHistory from "./UploadJobHistory";
16
+ import { StatusPill } from "./UploadJobParser";
17
+ import UploadJobResults from "./UploadJobResults";
18
+ import UploadJobSummary, { getUploadJobStatus } from "./UploadJobSummary";
19
+ import { getUploadJobScope } from "./UploadJobScope";
19
20
 
20
- export default function UploadJob({ scope }) {
21
- const { id } = useParams();
22
- const { data, loading } = useUploadJob(id);
23
- const job = data?.data;
21
+ export const UploadJobContent = ({ constrainResults, job, loading, scope }) => {
22
+ const lifecycleStatus = getUploadJobStatus(job?.events, job?.latest_status);
24
23
 
25
- const { formatMessage } = useIntl();
26
- const [expandedGroups, setExpandedGroups] = useState({});
24
+ return (
25
+ <Dimmer.Dimmable dimmed={loading}>
26
+ <Segment attached="bottom" className="upload-job-card">
27
+ {job ? (
28
+ <>
29
+ <div className="upload-job-meta">
30
+ <div className="upload-job-file">
31
+ <strong>{job.filename}</strong>
32
+ <span>{job.hash}</span>
33
+ </div>
34
+ <StatusPill status={job.latest_status} />
35
+ <span>
36
+ <Label basic size="small" className="upload-job-date-label">
37
+ <FormattedMessage
38
+ id="uploadJob.table.inserted_at"
39
+ defaultMessage="Inserted At"
40
+ />
41
+ </Label>
42
+ <DateTime value={job.latest_event_at} />
43
+ </span>
44
+ </div>
27
45
 
28
- // Group events by status
29
- const groupedEvents = job?.events ? _.groupBy("status", job.events) : {};
46
+ <UploadJobSummary
47
+ events={job.events}
48
+ latestStatus={job.latest_status}
49
+ latestResponse={job.latest_event_response}
50
+ />
51
+ <UploadJobHistory events={job.events} />
52
+ <UploadJobResults
53
+ constrainResults={constrainResults}
54
+ events={job.events}
55
+ lifecycleStatus={lifecycleStatus}
56
+ scope={scope}
57
+ />
58
+ </>
59
+ ) : null}
60
+ {loading ? (
61
+ <Dimmer active inverted>
62
+ <Loader />
63
+ </Dimmer>
64
+ ) : null}
65
+ </Segment>
66
+ </Dimmer.Dimmable>
67
+ );
68
+ };
30
69
 
31
- const toggleGroup = (status) => {
32
- setExpandedGroups((prev) => ({
33
- ...prev,
34
- [status]: !prev[status],
35
- }));
36
- };
70
+ UploadJobContent.propTypes = {
71
+ constrainResults: PropTypes.bool,
72
+ job: PropTypes.object,
73
+ loading: PropTypes.bool,
74
+ scope: PropTypes.string,
75
+ };
37
76
 
38
- const renderEventGroup = (status, events) => {
39
- const isExpanded = expandedGroups[status];
40
- const shouldCollapse = status === "INFO" || status === "ERROR";
77
+ UploadJobContent.defaultProps = {
78
+ constrainResults: false,
79
+ job: undefined,
80
+ loading: false,
81
+ scope: "implementations",
82
+ };
41
83
 
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
- }
84
+ export const UploadJobModalContent = ({ id, scope }) => {
85
+ const { data, loading } = useUploadJob(id);
58
86
 
59
- // Render collapsible groups for INFO and ERROR
60
- const groupRows = [];
87
+ return (
88
+ <UploadJobContent
89
+ constrainResults
90
+ job={data?.data}
91
+ loading={loading}
92
+ scope={scope}
93
+ />
94
+ );
95
+ };
61
96
 
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
- );
97
+ UploadJobModalContent.propTypes = {
98
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
99
+ scope: PropTypes.string,
100
+ };
84
101
 
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
- }
102
+ UploadJobModalContent.defaultProps = {
103
+ scope: "implementations",
104
+ };
103
105
 
104
- return groupRows;
105
- };
106
+ export default function UploadJob({ scope }) {
107
+ const { id } = useParams();
108
+ const { data, loading } = useUploadJob(id);
109
+ const job = data?.data;
110
+ const { formatMessage } = useIntl();
111
+ const scopeConfig = getUploadJobScope(scope);
106
112
 
107
113
  return (
108
114
  <>
109
115
  <UploadJobBreadcrumbs filename={job?.filename} scope={scope} />
110
- <Segment>
116
+ <Segment className="upload-job-page">
111
117
  <Header as="h2">
112
118
  <Icon
113
119
  circular
@@ -117,101 +123,19 @@ export default function UploadJob({ scope }) {
117
123
  })}
118
124
  />
119
125
  <Header.Content>
120
- <FormattedMessage id={`uploadJob.${scope}.header`} />
126
+ <FormattedMessage id={scopeConfig.detailHeaderId} />
121
127
  </Header.Content>
122
128
  </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>
129
+ <UploadJobContent job={job} loading={loading} scope={scope} />
214
130
  </Segment>
215
131
  </>
216
132
  );
217
133
  }
134
+
135
+ UploadJob.propTypes = {
136
+ scope: PropTypes.string,
137
+ };
138
+
139
+ UploadJob.defaultProps = {
140
+ scope: "implementations",
141
+ };
@@ -2,27 +2,23 @@ import PropTypes from "prop-types";
2
2
  import { Breadcrumb } from "semantic-ui-react";
3
3
  import { Link } from "react-router";
4
4
  import { FormattedMessage } from "react-intl";
5
- import {
6
- IMPLEMENTATIONS_UPLOAD_JOBS,
7
- STRUCTURE_NOTES_UPLOAD_JOBS,
8
- } from "@truedat/core/routes";
5
+ import { getUploadJobScope } from "./UploadJobScope";
9
6
 
10
7
  export const UploadJobBreadcrumbs = ({ filename, scope }) => {
11
- const uploadJobsPath = (scope) => {
12
- switch (scope) {
13
- case "implementations":
14
- return IMPLEMENTATIONS_UPLOAD_JOBS;
15
- case "notes":
16
- return STRUCTURE_NOTES_UPLOAD_JOBS;
17
- default:
18
- return null;
19
- }
20
- };
8
+ const scopeConfig = getUploadJobScope(scope);
9
+ const uploadJobsSection = scopeConfig.listPath ? (
10
+ <Breadcrumb.Section as={Link} to={scopeConfig.listPath}>
11
+ <FormattedMessage id={scopeConfig.listHeaderId} />
12
+ </Breadcrumb.Section>
13
+ ) : (
14
+ <Breadcrumb.Section>
15
+ <FormattedMessage id={scopeConfig.listHeaderId} />
16
+ </Breadcrumb.Section>
17
+ );
18
+
21
19
  return (
22
20
  <Breadcrumb>
23
- <Breadcrumb.Section as={Link} to={uploadJobsPath(scope)}>
24
- <FormattedMessage id={`uploadJobs.${scope}.header`} />
25
- </Breadcrumb.Section>
21
+ {uploadJobsSection}
26
22
  {filename ? (
27
23
  <>
28
24
  <Breadcrumb.Divider icon="right angle" />
@@ -0,0 +1,256 @@
1
+ import _ from "lodash/fp";
2
+ import PropTypes from "prop-types";
3
+ import { useId, useState } from "react";
4
+ import { FormattedMessage } from "react-intl";
5
+ import { getUploadJobScope } from "./UploadJobScope";
6
+
7
+ export const ErrorDetailsDisclosure = ({ details }) => {
8
+ const [isExpanded, setIsExpanded] = useState(false);
9
+ const detailsId = useId();
10
+
11
+ return (
12
+ <div className="upload-job-change-details upload-job-error-details">
13
+ <button
14
+ type="button"
15
+ className="upload-job-change-toggle upload-job-error-toggle"
16
+ aria-controls={detailsId}
17
+ aria-expanded={isExpanded}
18
+ onClick={() => setIsExpanded(!isExpanded)}
19
+ >
20
+ <span aria-hidden="true">{isExpanded ? "▼" : "▶"}</span>
21
+ <FormattedMessage
22
+ id="uploadJob.results.errors.errorFields"
23
+ values={{ count: details.length }}
24
+ />
25
+ </button>
26
+ <div
27
+ id={detailsId}
28
+ className="upload-job-change-table-wrap"
29
+ hidden={!isExpanded}
30
+ >
31
+ {isExpanded ? (
32
+ <table className="upload-job-change-table upload-job-error-table">
33
+ <thead>
34
+ <tr>
35
+ <th>
36
+ <FormattedMessage id="uploadJob.results.errors.field" />
37
+ </th>
38
+ <th>
39
+ <FormattedMessage id="uploadJob.results.errors.error" />
40
+ </th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {details.map(([field, error], index) => (
45
+ <tr key={`${field ?? "message"}-${index}`}>
46
+ <th scope="row">
47
+ {field ?? (
48
+ <FormattedMessage id="uploadJob.parser.detail.message" />
49
+ )}
50
+ </th>
51
+ <td>{error}</td>
52
+ </tr>
53
+ ))}
54
+ </tbody>
55
+ </table>
56
+ ) : null}
57
+ </div>
58
+ </div>
59
+ );
60
+ };
61
+
62
+ ErrorDetailsDisclosure.propTypes = {
63
+ details: PropTypes.arrayOf(PropTypes.array),
64
+ };
65
+
66
+ ErrorDetailsDisclosure.defaultProps = {
67
+ details: [],
68
+ };
69
+
70
+ const isChangePair = (value) =>
71
+ _.isObject(value) &&
72
+ (Object.prototype.hasOwnProperty.call(value, "old") ||
73
+ Object.prototype.hasOwnProperty.call(value, "new"));
74
+
75
+ export const flattenChanges = (changes, parent = "") =>
76
+ _.toPairs(changes).flatMap(([key, value]) => {
77
+ const path = parent ? `${parent}.${key}` : key;
78
+ if (isChangePair(value)) {
79
+ return [{ key: path, oldValue: value.old, newValue: value.new }];
80
+ }
81
+ if (
82
+ _.isObject(value) &&
83
+ !_.isArray(value) &&
84
+ (key === "df_content" || containsChangePair(value))
85
+ ) {
86
+ return flattenChanges(value, path);
87
+ }
88
+ return [{ key: path, oldValue: undefined, newValue: value }];
89
+ });
90
+
91
+ const containsChangePair = (value) =>
92
+ _.isObject(value) &&
93
+ (isChangePair(value) ||
94
+ _.toPairs(value).some(([, nestedValue]) =>
95
+ containsChangePair(nestedValue),
96
+ ));
97
+
98
+ export const ChangeField = ({ path, scope }) => {
99
+ const [root, ...nested] = path.split(".");
100
+ const field = nested.at(-1) ?? root;
101
+ const config = getUploadJobScope(scope);
102
+
103
+ return (
104
+ <FormattedMessage
105
+ id={
106
+ config.fieldMessageOverrides?.[field] ??
107
+ `${config.fieldMessagePrefix}.${field}`
108
+ }
109
+ defaultMessage={field}
110
+ />
111
+ );
112
+ };
113
+
114
+ ChangeField.propTypes = {
115
+ path: PropTypes.string.isRequired,
116
+ scope: PropTypes.string,
117
+ };
118
+
119
+ ChangeField.defaultProps = {
120
+ scope: "implementations",
121
+ };
122
+
123
+ const formatValue = (value, key) => {
124
+ if (_.has("value")(value)) return formatValue(value.value, key);
125
+ if (_.isArray(value)) {
126
+ return _.flow(
127
+ _.map((v) => formatValue(v, key)),
128
+ _.join(", "),
129
+ )(value);
130
+ }
131
+ if (_.isObject(value)) {
132
+ if (_.has("url_value")(value))
133
+ return `[${value.url_name}] (${value.url_value})`;
134
+ if (_.has("name")(value)) return value.name;
135
+ if (_.has("external_id")(value)) return value.external_id;
136
+ return _.flow(_.keys, _.join(", "))(value);
137
+ }
138
+ return value;
139
+ };
140
+
141
+ const ChangeValue = ({ className, value }) => {
142
+ const displayValue = value == null ? "—" : String(formatValue(value));
143
+
144
+ return (
145
+ <span className={className} title={displayValue}>
146
+ {displayValue}
147
+ </span>
148
+ );
149
+ };
150
+
151
+ ChangeValue.propTypes = {
152
+ className: PropTypes.string.isRequired,
153
+ value: PropTypes.oneOfType([
154
+ PropTypes.string,
155
+ PropTypes.number,
156
+ PropTypes.bool,
157
+ PropTypes.array,
158
+ PropTypes.object,
159
+ ]),
160
+ };
161
+
162
+ ChangeValue.defaultProps = {
163
+ value: undefined,
164
+ };
165
+
166
+ export const ChangesDetail = ({ changes, changeDetails, scope }) => {
167
+ const [isExpanded, setIsExpanded] = useState(false);
168
+ const detailsId = useId();
169
+ const details = changeDetails ?? changes;
170
+
171
+ if (!details) return null;
172
+
173
+ const changesList = flattenChanges(details);
174
+
175
+ return (
176
+ <div className="upload-job-change-details">
177
+ <button
178
+ type="button"
179
+ className="upload-job-change-toggle"
180
+ aria-controls={detailsId}
181
+ aria-expanded={isExpanded}
182
+ onClick={() => setIsExpanded(!isExpanded)}
183
+ >
184
+ <span aria-hidden="true">{isExpanded ? "▼" : "▶"}</span>
185
+ <FormattedMessage
186
+ id="uploadJob.results.changes.modifiedFields"
187
+ values={{ count: changesList.length }}
188
+ />
189
+ </button>
190
+ <div
191
+ id={detailsId}
192
+ className="upload-job-change-table-wrap"
193
+ hidden={!isExpanded}
194
+ >
195
+ {isExpanded ? (
196
+ <table className="upload-job-change-table">
197
+ <thead>
198
+ <tr>
199
+ <th>
200
+ <FormattedMessage id="uploadJob.results.changes.field" />
201
+ </th>
202
+ <th>
203
+ <FormattedMessage id="uploadJob.results.changes.previous" />
204
+ </th>
205
+ <th>
206
+ <span className="upload-job-visually-hidden">
207
+ <FormattedMessage id="uploadJob.results.changes.change" />
208
+ </span>
209
+ </th>
210
+ <th>
211
+ <FormattedMessage id="uploadJob.results.changes.new" />
212
+ </th>
213
+ </tr>
214
+ </thead>
215
+ <tbody>
216
+ {changesList.map(({ key, oldValue, newValue }) => (
217
+ <tr key={key}>
218
+ <th scope="row">
219
+ <ChangeField path={key} scope={scope} />
220
+ </th>
221
+ <td>
222
+ <ChangeValue
223
+ className="upload-job-old-value upload-job-change-value"
224
+ value={oldValue}
225
+ />
226
+ </td>
227
+ <td className="upload-job-change-arrow">
228
+ <span aria-hidden="true">→</span>
229
+ </td>
230
+ <td>
231
+ <ChangeValue
232
+ className="upload-job-new-value upload-job-change-value"
233
+ value={newValue}
234
+ />
235
+ </td>
236
+ </tr>
237
+ ))}
238
+ </tbody>
239
+ </table>
240
+ ) : null}
241
+ </div>
242
+ </div>
243
+ );
244
+ };
245
+
246
+ ChangesDetail.propTypes = {
247
+ changeDetails: PropTypes.object,
248
+ changes: PropTypes.object,
249
+ scope: PropTypes.string,
250
+ };
251
+
252
+ ChangesDetail.defaultProps = {
253
+ changeDetails: undefined,
254
+ changes: undefined,
255
+ scope: "implementations",
256
+ };