@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 +3 -3
- package/src/components/UploadJob.js +102 -178
- package/src/components/UploadJobBreadcrumbs.js +13 -17
- package/src/components/UploadJobDetails.js +256 -0
- package/src/components/UploadJobHistory.js +90 -0
- package/src/components/UploadJobParser.js +155 -89
- package/src/components/UploadJobResults.js +278 -0
- package/src/components/UploadJobScope.js +37 -0
- package/src/components/UploadJobSummary.js +211 -0
- package/src/components/UploadJobs.js +48 -17
- package/src/components/__tests__/UploadJob.spec.js +118 -42
- package/src/components/__tests__/UploadJobBreadcrumbs.spec.js +3 -0
- package/src/components/__tests__/UploadJobHistory.spec.js +71 -0
- package/src/components/__tests__/UploadJobParser.spec.js +560 -26
- package/src/components/__tests__/UploadJobResults.spec.js +289 -0
- package/src/components/__tests__/UploadJobScope.spec.js +38 -0
- package/src/components/__tests__/UploadJobSummary.spec.js +208 -0
- package/src/components/__tests__/UploadJobs.spec.js +133 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { render } from "@truedat/test/render";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import UploadJobResults, {
|
|
4
|
+
getResultEvents,
|
|
5
|
+
getResultOutcome,
|
|
6
|
+
} from "../UploadJobResults";
|
|
7
|
+
|
|
8
|
+
const messages = {
|
|
9
|
+
en: {
|
|
10
|
+
"uploadJob.results.title": "Processing results",
|
|
11
|
+
"uploadJob.results.subtitle": "Browse and filter records by upload result.",
|
|
12
|
+
"uploadJob.results.expand": "Expand processing results",
|
|
13
|
+
"uploadJob.results.collapse": "Collapse processing results",
|
|
14
|
+
"uploadJob.results.filter.all": "All",
|
|
15
|
+
"uploadJob.results.filter.created": "Created",
|
|
16
|
+
"uploadJob.results.filter.updated": "Updated",
|
|
17
|
+
"uploadJob.results.filter.unchanged": "Unchanged",
|
|
18
|
+
"uploadJob.results.filter.error": "Errors",
|
|
19
|
+
"uploadJob.results.filter.label": "Filter results",
|
|
20
|
+
"uploadJob.results.column.outcome": "Result",
|
|
21
|
+
"uploadJob.results.column.processedAt": "Processed at",
|
|
22
|
+
"uploadJob.results.column.implementation": "Implementation",
|
|
23
|
+
"uploadJob.results.column.structure": "Structure",
|
|
24
|
+
"uploadJob.results.column.origin": "Origin",
|
|
25
|
+
"uploadJob.results.origin.row": "Row {rowNumber}",
|
|
26
|
+
"uploadJob.results.empty": "No results in this category.",
|
|
27
|
+
"uploadJob.table.detail": "Detail",
|
|
28
|
+
"uploadJob.parser.info.created": "Created",
|
|
29
|
+
"uploadJob.parser.info.updated": "Updated",
|
|
30
|
+
"uploadJob.parser.info.unchanged": "Unchanged",
|
|
31
|
+
"uploadJob.parser.event.status.ERROR": "Error",
|
|
32
|
+
"uploadJob.parser.event.status.INFO": "Info",
|
|
33
|
+
"uploadJob.parser.error.invalid_template_name": "Invalid template name",
|
|
34
|
+
"uploadJob.parser.detail.templateName": "Template name",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const events = [
|
|
39
|
+
{
|
|
40
|
+
status: "INFO",
|
|
41
|
+
inserted_at: "2026-01-01T10:00:00Z",
|
|
42
|
+
response: {
|
|
43
|
+
type: "created",
|
|
44
|
+
sheet: "Template A",
|
|
45
|
+
row_number: 2,
|
|
46
|
+
details: { implementation_key: "created-key" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
status: "INFO",
|
|
51
|
+
inserted_at: "2026-01-01T10:01:00Z",
|
|
52
|
+
response: {
|
|
53
|
+
type: "updated",
|
|
54
|
+
sheet: "Template A",
|
|
55
|
+
row_number: 3,
|
|
56
|
+
details: { implementation_key: "updated-key" },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
status: "INFO",
|
|
61
|
+
inserted_at: "2026-01-01T10:02:00Z",
|
|
62
|
+
response: {
|
|
63
|
+
type: "unchanged",
|
|
64
|
+
sheet: "Template B",
|
|
65
|
+
row_number: 4,
|
|
66
|
+
details: { implementation_key: "unchanged-key" },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
status: "ERROR",
|
|
71
|
+
inserted_at: "2026-01-01T10:03:00Z",
|
|
72
|
+
response: {
|
|
73
|
+
type: "invalid_template_name",
|
|
74
|
+
sheet: "Template C",
|
|
75
|
+
row_number: 5,
|
|
76
|
+
details: { template_name: "missing" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
describe("<UploadJobResults />", () => {
|
|
82
|
+
it.each(["PENDING", "STARTED", "FAILED"])(
|
|
83
|
+
"is collapsed and disabled for %s",
|
|
84
|
+
async (status) => {
|
|
85
|
+
const user = userEvent.setup({ delay: null });
|
|
86
|
+
const rendered = render(
|
|
87
|
+
<UploadJobResults events={events} lifecycleStatus={status} />,
|
|
88
|
+
{ messages },
|
|
89
|
+
);
|
|
90
|
+
const toggle = rendered.getByRole("button", {
|
|
91
|
+
name: /^Processing results/i,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(toggle).toBeDisabled();
|
|
95
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
96
|
+
await user.click(toggle);
|
|
97
|
+
expect(rendered.queryByRole("table")).not.toBeInTheDocument();
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
it("opens completed results and always displays every filter without counters", async () => {
|
|
102
|
+
const user = userEvent.setup({ delay: null });
|
|
103
|
+
const rendered = render(
|
|
104
|
+
<UploadJobResults events={events} lifecycleStatus="COMPLETED" />,
|
|
105
|
+
{ messages },
|
|
106
|
+
);
|
|
107
|
+
const toggle = rendered.getByRole("button", {
|
|
108
|
+
name: /^Processing results/i,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
112
|
+
expect(toggle).toHaveAttribute("aria-controls");
|
|
113
|
+
const resultsSection = rendered
|
|
114
|
+
.getByText("Processing results")
|
|
115
|
+
.closest("section");
|
|
116
|
+
expect(
|
|
117
|
+
resultsSection.querySelector(`#${toggle.getAttribute("aria-controls")}`),
|
|
118
|
+
).toHaveAttribute("hidden");
|
|
119
|
+
await user.click(toggle);
|
|
120
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
121
|
+
|
|
122
|
+
const filters = rendered.getByRole("group", { name: "Filter results" });
|
|
123
|
+
expect(filters).toHaveTextContent("All");
|
|
124
|
+
expect(filters).toHaveTextContent("Created");
|
|
125
|
+
expect(filters).toHaveTextContent("Updated");
|
|
126
|
+
expect(filters).toHaveTextContent("Unchanged");
|
|
127
|
+
expect(filters).toHaveTextContent("Errors");
|
|
128
|
+
expect(
|
|
129
|
+
rendered.getByRole("button", { name: "Created" }),
|
|
130
|
+
).toBeInTheDocument();
|
|
131
|
+
expect(
|
|
132
|
+
rendered.getByRole("button", { name: "Updated" }),
|
|
133
|
+
).toBeInTheDocument();
|
|
134
|
+
const allFilter = rendered.getByRole("button", { name: "All" });
|
|
135
|
+
const createdFilter = rendered.getByRole("button", { name: "Created" });
|
|
136
|
+
const errorFilter = rendered.getByRole("button", { name: "Errors" });
|
|
137
|
+
|
|
138
|
+
expect(allFilter).toHaveAttribute("aria-pressed", "true");
|
|
139
|
+
expect(allFilter).toHaveClass("active");
|
|
140
|
+
expect(createdFilter).toHaveAttribute("aria-pressed", "false");
|
|
141
|
+
expect(errorFilter).toHaveClass("upload-job-result-filter-error");
|
|
142
|
+
expect(filters).not.toHaveTextContent(/\d/);
|
|
143
|
+
expect(rendered.getByText("Implementation")).toBeInTheDocument();
|
|
144
|
+
expect(rendered.getByText("Detail")).toBeInTheDocument();
|
|
145
|
+
expect(rendered.getByText("updated-key")).toBeInTheDocument();
|
|
146
|
+
expect(rendered.getAllByText("Created")[1].closest(".label")).toHaveClass(
|
|
147
|
+
"upload-job-outcome-created",
|
|
148
|
+
);
|
|
149
|
+
expect(rendered.getAllByText("Updated")[1].closest(".label")).toHaveClass(
|
|
150
|
+
"upload-job-outcome-updated",
|
|
151
|
+
);
|
|
152
|
+
expect(
|
|
153
|
+
rendered.container
|
|
154
|
+
.querySelector(".upload-job-outcome-unchanged")
|
|
155
|
+
.closest(".label"),
|
|
156
|
+
).toHaveClass("upload-job-outcome", "upload-job-outcome-unchanged", "grey");
|
|
157
|
+
expect(rendered.getByText("Error").closest(".label")).toHaveClass(
|
|
158
|
+
"upload-job-outcome",
|
|
159
|
+
"upload-job-outcome-error",
|
|
160
|
+
"red",
|
|
161
|
+
);
|
|
162
|
+
expect(rendered.getAllByText(/Template A/)).toHaveLength(2);
|
|
163
|
+
|
|
164
|
+
await user.click(createdFilter);
|
|
165
|
+
|
|
166
|
+
expect(allFilter).toHaveAttribute("aria-pressed", "false");
|
|
167
|
+
expect(allFilter).not.toHaveClass("active");
|
|
168
|
+
expect(createdFilter).toHaveAttribute("aria-pressed", "true");
|
|
169
|
+
expect(createdFilter).toHaveClass("active");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("uses one trigger to label and control the results section", () => {
|
|
173
|
+
const rendered = render(
|
|
174
|
+
<UploadJobResults events={events} lifecycleStatus="COMPLETED" />,
|
|
175
|
+
{ messages },
|
|
176
|
+
);
|
|
177
|
+
const trigger = rendered.getByRole("button", {
|
|
178
|
+
name: /^Processing results/i,
|
|
179
|
+
});
|
|
180
|
+
const section = trigger.closest("section");
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
rendered.getAllByRole("button", { name: /^Processing results/i }),
|
|
184
|
+
).toHaveLength(1);
|
|
185
|
+
expect(section).toHaveAttribute("aria-labelledby");
|
|
186
|
+
expect(
|
|
187
|
+
section.querySelector(`#${section.getAttribute("aria-labelledby")}`),
|
|
188
|
+
).toBeTruthy();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("constrains table scrolling only when rendered in a modal", async () => {
|
|
192
|
+
const user = userEvent.setup({ delay: null });
|
|
193
|
+
const constrained = render(
|
|
194
|
+
<UploadJobResults
|
|
195
|
+
constrainResults
|
|
196
|
+
events={events}
|
|
197
|
+
lifecycleStatus="COMPLETED"
|
|
198
|
+
/>,
|
|
199
|
+
{ messages },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await user.click(
|
|
203
|
+
constrained.getByRole("button", { name: /^Processing results/i }),
|
|
204
|
+
);
|
|
205
|
+
expect(
|
|
206
|
+
constrained.container.querySelector(".upload-job-table-wrap"),
|
|
207
|
+
).toHaveClass("upload-job-table-wrap-constrained");
|
|
208
|
+
expect(
|
|
209
|
+
constrained.container.querySelector(".upload-job-table-wrap"),
|
|
210
|
+
).toHaveStyle({ maxHeight: "48vh", overflow: "auto" });
|
|
211
|
+
constrained.unmount();
|
|
212
|
+
|
|
213
|
+
const direct = render(
|
|
214
|
+
<UploadJobResults events={events} lifecycleStatus="COMPLETED" />,
|
|
215
|
+
{ messages },
|
|
216
|
+
);
|
|
217
|
+
await user.click(
|
|
218
|
+
direct.getByRole("button", { name: /^Processing results/i }),
|
|
219
|
+
);
|
|
220
|
+
expect(
|
|
221
|
+
direct.container.querySelector(".upload-job-table-wrap"),
|
|
222
|
+
).not.toHaveClass("upload-job-table-wrap-constrained");
|
|
223
|
+
expect(
|
|
224
|
+
direct.container.querySelector(".upload-job-table-wrap"),
|
|
225
|
+
).not.toHaveAttribute("style");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("filters categories and shows an empty state for a missing category", async () => {
|
|
229
|
+
const user = userEvent.setup({ delay: null });
|
|
230
|
+
const rendered = render(
|
|
231
|
+
<UploadJobResults
|
|
232
|
+
events={events.slice(0, 2)}
|
|
233
|
+
lifecycleStatus="COMPLETED"
|
|
234
|
+
/>,
|
|
235
|
+
{ messages },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await user.click(
|
|
239
|
+
rendered.getByRole("button", { name: /^Processing results/i }),
|
|
240
|
+
);
|
|
241
|
+
await user.click(
|
|
242
|
+
rendered.getByRole("button", {
|
|
243
|
+
name: "Unchanged",
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(
|
|
248
|
+
rendered.getByText("No results in this category."),
|
|
249
|
+
).toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("renders the entity header according to scope", async () => {
|
|
253
|
+
const user = userEvent.setup({ delay: null });
|
|
254
|
+
const renderExpanded = async (scope) => {
|
|
255
|
+
const rendered = render(
|
|
256
|
+
<UploadJobResults
|
|
257
|
+
events={events}
|
|
258
|
+
lifecycleStatus="COMPLETED"
|
|
259
|
+
scope={scope}
|
|
260
|
+
/>,
|
|
261
|
+
{ messages },
|
|
262
|
+
);
|
|
263
|
+
await user.click(
|
|
264
|
+
rendered.getByRole("button", { name: /^Processing results/i }),
|
|
265
|
+
);
|
|
266
|
+
return rendered;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const implementationsView = await renderExpanded("implementations");
|
|
270
|
+
expect(implementationsView.getByText("Implementation")).toBeInTheDocument();
|
|
271
|
+
expect(
|
|
272
|
+
implementationsView.queryByText("Structure"),
|
|
273
|
+
).not.toBeInTheDocument();
|
|
274
|
+
implementationsView.unmount();
|
|
275
|
+
|
|
276
|
+
const notesView = await renderExpanded("notes");
|
|
277
|
+
expect(notesView.getByText("Structure")).toBeInTheDocument();
|
|
278
|
+
expect(notesView.queryByText("Implementation")).not.toBeInTheDocument();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("classifies known outcomes and preserves unknown INFO safely", () => {
|
|
282
|
+
expect(getResultEvents(events)).toEqual(events);
|
|
283
|
+
expect(getResultOutcome(events[0])).toBe("created");
|
|
284
|
+
expect(getResultOutcome(events[1])).toBe("updated");
|
|
285
|
+
expect(getResultOutcome(events[2])).toBe("unchanged");
|
|
286
|
+
expect(getResultOutcome(events[3])).toBe("error");
|
|
287
|
+
expect(getResultOutcome({ status: "INFO", response: {} })).toBe("unknown");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getUploadJobScope, uploadJobScopes } from "../UploadJobScope";
|
|
2
|
+
|
|
3
|
+
describe("Upload job scopes", () => {
|
|
4
|
+
it("defines implementation and notes upload routes and presentation metadata", () => {
|
|
5
|
+
expect(uploadJobScopes.implementations.listPath).toBe(
|
|
6
|
+
"/implementations/uploadJobs",
|
|
7
|
+
);
|
|
8
|
+
expect(uploadJobScopes.implementations.listHeaderId).toBe(
|
|
9
|
+
"uploadJobs.implementations.header",
|
|
10
|
+
);
|
|
11
|
+
expect(uploadJobScopes.implementations.detailHeaderId).toBe(
|
|
12
|
+
"uploadJob.implementations.header",
|
|
13
|
+
);
|
|
14
|
+
expect(uploadJobScopes.implementations.detailPath(12)).toBe(
|
|
15
|
+
"/implementations/uploadJobs/12",
|
|
16
|
+
);
|
|
17
|
+
expect(uploadJobScopes.notes.listPath).toBe("/structureNotes/uploadJobs");
|
|
18
|
+
expect(uploadJobScopes.notes.listHeaderId).toBe("uploadJobs.notes.header");
|
|
19
|
+
expect(uploadJobScopes.notes.detailHeaderId).toBe("uploadJob.notes.header");
|
|
20
|
+
expect(uploadJobScopes.notes.detailPath(12)).toBe(
|
|
21
|
+
"/structureNotes/uploadJobs/12",
|
|
22
|
+
);
|
|
23
|
+
expect(uploadJobScopes.notes.fieldMessageOverrides.status).toBe(
|
|
24
|
+
"structure.notes.status",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back to implementation semantics for unknown scopes", () => {
|
|
29
|
+
expect(getUploadJobScope("other")).toMatchObject({
|
|
30
|
+
listPath: null,
|
|
31
|
+
detailPath: null,
|
|
32
|
+
listHeaderId: "uploadJobs.other.header",
|
|
33
|
+
detailHeaderId: "uploadJob.other.header",
|
|
34
|
+
entityColumnMessageId: "uploadJob.results.column.implementation",
|
|
35
|
+
fieldMessagePrefix: "ruleImplementations.props",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { render } from "@truedat/test/render";
|
|
2
|
+
|
|
3
|
+
import UploadJobSummary, {
|
|
4
|
+
getUploadJobStatus,
|
|
5
|
+
getUploadJobSummary,
|
|
6
|
+
classifyUploadJobErrorMessage,
|
|
7
|
+
} from "../UploadJobSummary";
|
|
8
|
+
|
|
9
|
+
const summaryMessages = {
|
|
10
|
+
en: {
|
|
11
|
+
"uploadJob.summary.title": "Summary",
|
|
12
|
+
"uploadJob.summary.total": "Total processed",
|
|
13
|
+
"uploadJob.summary.pending": "The file is pending processing.",
|
|
14
|
+
"uploadJob.summary.started": "The file is being processed.",
|
|
15
|
+
"uploadJob.summary.failed": "Error processing the file: {detail}",
|
|
16
|
+
"uploadJob.parser.error.invalid_format": "Invalid file format",
|
|
17
|
+
"uploadJob.parser.error.unknown": "Unknown error",
|
|
18
|
+
"uploadJob.parser.error.unexpected_error": "Unexpected error",
|
|
19
|
+
"uploadJob.parser.result.summary.created": "Created",
|
|
20
|
+
"uploadJob.parser.result.summary.updated": "Updated",
|
|
21
|
+
"uploadJob.parser.result.summary.unchanged": "Unchanged",
|
|
22
|
+
"uploadJob.parser.result.summary.errors": "Errors",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("<UploadJobSummary />", () => {
|
|
27
|
+
it("renders completed counters with the visual total first", () => {
|
|
28
|
+
const events = [
|
|
29
|
+
{ status: "PENDING" },
|
|
30
|
+
{ status: "STARTED" },
|
|
31
|
+
{
|
|
32
|
+
status: "COMPLETED",
|
|
33
|
+
response: {
|
|
34
|
+
insert_count: 2,
|
|
35
|
+
update_count: 1,
|
|
36
|
+
unchanged_count: 7,
|
|
37
|
+
error_count: 2,
|
|
38
|
+
invalid_sheet_count: 1,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const rendered = render(
|
|
44
|
+
<UploadJobSummary events={events} latestStatus="COMPLETED" />,
|
|
45
|
+
{ messages: summaryMessages },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const message = rendered.getByLabelText("Summary");
|
|
49
|
+
expect(message).toHaveClass("positive");
|
|
50
|
+
expect(message).toHaveTextContent("Total processed: 13");
|
|
51
|
+
expect(message).toHaveTextContent("Created: 2");
|
|
52
|
+
expect(message).toHaveTextContent("Updated: 1");
|
|
53
|
+
expect(message).toHaveTextContent("Unchanged: 7");
|
|
54
|
+
expect(message).toHaveTextContent("Errors: 3");
|
|
55
|
+
expect(message.textContent.indexOf("Total processed")).toBeLessThan(
|
|
56
|
+
message.textContent.indexOf("Created"),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
["PENDING", "warning", "pending processing"],
|
|
62
|
+
["STARTED", "info", "being processed"],
|
|
63
|
+
])("renders %s as %s", (status, className, text) => {
|
|
64
|
+
const rendered = render(
|
|
65
|
+
<UploadJobSummary events={[{ status }]} latestStatus={status} />,
|
|
66
|
+
{ messages: summaryMessages },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const message = rendered.getByLabelText("Summary");
|
|
70
|
+
expect(message).toHaveClass(className);
|
|
71
|
+
expect(message).toHaveTextContent(text);
|
|
72
|
+
expect(message).not.toHaveTextContent("Created:");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders the translated processing error without counters", () => {
|
|
76
|
+
const rendered = render(
|
|
77
|
+
<UploadJobSummary
|
|
78
|
+
events={[
|
|
79
|
+
{ status: "STARTED" },
|
|
80
|
+
{ status: "FAILED", response: { message: "invalid_format" } },
|
|
81
|
+
]}
|
|
82
|
+
latestStatus="FAILED"
|
|
83
|
+
/>,
|
|
84
|
+
{ messages: summaryMessages },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const message = rendered.getByLabelText("Summary");
|
|
88
|
+
expect(message).toHaveClass("negative");
|
|
89
|
+
expect(message).toHaveTextContent(
|
|
90
|
+
"Error processing the file: Invalid file format",
|
|
91
|
+
);
|
|
92
|
+
expect(message).not.toHaveTextContent("Created:");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders the translated unknown error when FAILED has no message", () => {
|
|
96
|
+
const rendered = render(
|
|
97
|
+
<UploadJobSummary
|
|
98
|
+
events={[{ status: "FAILED" }]}
|
|
99
|
+
latestStatus="FAILED"
|
|
100
|
+
/>,
|
|
101
|
+
{ messages: summaryMessages },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(rendered.getByLabelText("Summary")).toHaveTextContent(
|
|
105
|
+
"Error processing the file: Unknown error",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("replaces technical failure details with an unexpected error", () => {
|
|
110
|
+
const technicalMessage = "%KeyError{} key :foo not found in: %{}";
|
|
111
|
+
const rendered = render(
|
|
112
|
+
<UploadJobSummary
|
|
113
|
+
events={[{ status: "FAILED", response: { message: technicalMessage } }]}
|
|
114
|
+
latestStatus="FAILED"
|
|
115
|
+
/>,
|
|
116
|
+
{ messages: summaryMessages },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(rendered.getByLabelText("Summary")).toHaveTextContent(
|
|
120
|
+
"Error processing the file: Unexpected error",
|
|
121
|
+
);
|
|
122
|
+
expect(rendered.getByLabelText("Summary")).not.toHaveTextContent(
|
|
123
|
+
technicalMessage,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("classifies Elixir tuple inspections as unexpected errors", () => {
|
|
128
|
+
expect(classifyUploadJobErrorMessage("{:error, :boom}")).toEqual({
|
|
129
|
+
type: "unexpected_error",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders a human failure message", () => {
|
|
134
|
+
const rendered = render(
|
|
135
|
+
<UploadJobSummary
|
|
136
|
+
events={[
|
|
137
|
+
{
|
|
138
|
+
status: "FAILED",
|
|
139
|
+
response: { message: "The uploaded file is invalid" },
|
|
140
|
+
},
|
|
141
|
+
]}
|
|
142
|
+
latestStatus="FAILED"
|
|
143
|
+
/>,
|
|
144
|
+
{ messages: summaryMessages },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(rendered.getByLabelText("Summary")).toHaveTextContent(
|
|
148
|
+
"Error processing the file: The uploaded file is invalid",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("preserves multiline human failure messages", () => {
|
|
153
|
+
const humanMessage =
|
|
154
|
+
"The uploaded file is invalid\nPlease check its headers";
|
|
155
|
+
const rendered = render(
|
|
156
|
+
<UploadJobSummary
|
|
157
|
+
events={[{ status: "FAILED", response: { message: humanMessage } }]}
|
|
158
|
+
latestStatus="FAILED"
|
|
159
|
+
/>,
|
|
160
|
+
{ messages: summaryMessages },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(rendered.getByLabelText("Summary")).toHaveTextContent(
|
|
164
|
+
"Error processing the file: The uploaded file is invalid Please check its headers",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("uses latest response as a historical completed fallback", () => {
|
|
169
|
+
expect(
|
|
170
|
+
getUploadJobSummary([{ status: "COMPLETED", response: {} }], {
|
|
171
|
+
insert_count: 1,
|
|
172
|
+
update_count: 2,
|
|
173
|
+
unchanged_count: 3,
|
|
174
|
+
error_count: 4,
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({
|
|
177
|
+
insert_count: 1,
|
|
178
|
+
update_count: 2,
|
|
179
|
+
unchanged_count: 3,
|
|
180
|
+
error_count: 4,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("derives partial counters when no final response exists", () => {
|
|
185
|
+
expect(
|
|
186
|
+
getUploadJobSummary([
|
|
187
|
+
{ status: "INFO", response: { type: "created" } },
|
|
188
|
+
{ status: "INFO", response: { type: "updated" } },
|
|
189
|
+
{ status: "INFO", response: { type: "unchanged" } },
|
|
190
|
+
{ status: "ERROR", response: { type: "invalid_template_name" } },
|
|
191
|
+
]),
|
|
192
|
+
).toEqual({
|
|
193
|
+
insert_count: 1,
|
|
194
|
+
update_count: 1,
|
|
195
|
+
unchanged_count: 1,
|
|
196
|
+
error_count: 1,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("ignores row statuses when determining processing status", () => {
|
|
201
|
+
expect(
|
|
202
|
+
getUploadJobStatus(
|
|
203
|
+
[{ status: "PENDING" }, { status: "STARTED" }, { status: "ERROR" }],
|
|
204
|
+
"ERROR",
|
|
205
|
+
),
|
|
206
|
+
).toBe("STARTED");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1,12 +1,144 @@
|
|
|
1
1
|
import { render, waitForLoad } from "@truedat/test/render";
|
|
2
|
-
import
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { useUploadJob, useUploadJobs } from "../../hooks/useUploadJobs";
|
|
3
4
|
import UploadJobs from "../UploadJobs";
|
|
4
5
|
|
|
5
6
|
jest.mock("../../hooks/useUploadJobs", () => ({
|
|
6
7
|
useUploadJobs: jest.fn(),
|
|
8
|
+
useUploadJob: jest.fn(),
|
|
7
9
|
}));
|
|
8
10
|
|
|
11
|
+
const messages = {
|
|
12
|
+
en: {
|
|
13
|
+
"uploadJob.implementations.header": "Implementations upload detail",
|
|
14
|
+
"uploadJob.notes.header": "Structure notes upload detail",
|
|
15
|
+
"uploadJob.results.title": "Processing results",
|
|
16
|
+
"uploadJob.results.subtitle": "Browse results",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
9
20
|
describe("<UploadJobs />", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
useUploadJob.mockReset();
|
|
23
|
+
useUploadJob.mockReturnValue({ loading: false, data: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const mockJobs = [
|
|
27
|
+
{
|
|
28
|
+
id: 1,
|
|
29
|
+
filename: "test.csv",
|
|
30
|
+
latest_status: "completed",
|
|
31
|
+
latest_event_response: "Success",
|
|
32
|
+
latest_event_at: "2023-01-01T00:00:00Z",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 2,
|
|
36
|
+
filename: "other.csv",
|
|
37
|
+
latest_status: "completed",
|
|
38
|
+
latest_event_response: "Success",
|
|
39
|
+
latest_event_at: "2023-01-01T00:00:00Z",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
it("opens the TrueModal with the clicked job's detail without changing the route", async () => {
|
|
44
|
+
const user = userEvent.setup({ delay: null });
|
|
45
|
+
useUploadJobs.mockReturnValue({ loading: false, data: { data: mockJobs } });
|
|
46
|
+
|
|
47
|
+
const rendered = render(<UploadJobs scope="implementations" />, {
|
|
48
|
+
messages,
|
|
49
|
+
});
|
|
50
|
+
await waitForLoad(rendered);
|
|
51
|
+
|
|
52
|
+
expect(rendered.queryByRole("dialog")).not.toBeInTheDocument();
|
|
53
|
+
expect(useUploadJob).not.toHaveBeenCalled();
|
|
54
|
+
|
|
55
|
+
await user.click(rendered.getByText("other.csv"));
|
|
56
|
+
|
|
57
|
+
expect(rendered.getByRole("dialog")).toBeInTheDocument();
|
|
58
|
+
expect(
|
|
59
|
+
rendered.getByText("Implementations upload detail"),
|
|
60
|
+
).toBeInTheDocument();
|
|
61
|
+
expect(useUploadJob).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(useUploadJob).toHaveBeenCalledWith(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("propagates the notes scope to the modal title", async () => {
|
|
66
|
+
const user = userEvent.setup({ delay: null });
|
|
67
|
+
useUploadJobs.mockReturnValue({ loading: false, data: { data: mockJobs } });
|
|
68
|
+
|
|
69
|
+
const rendered = render(<UploadJobs scope="notes" />, { messages });
|
|
70
|
+
await waitForLoad(rendered);
|
|
71
|
+
|
|
72
|
+
await user.click(rendered.getByText("test.csv"));
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
rendered.getByText("Structure notes upload detail"),
|
|
76
|
+
).toBeInTheDocument();
|
|
77
|
+
expect(useUploadJob).toHaveBeenCalledWith(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("constrains expanded results inside the modal", async () => {
|
|
81
|
+
const user = userEvent.setup({ delay: null });
|
|
82
|
+
useUploadJobs.mockReturnValue({ loading: false, data: { data: mockJobs } });
|
|
83
|
+
useUploadJob.mockReturnValue({
|
|
84
|
+
loading: false,
|
|
85
|
+
data: {
|
|
86
|
+
data: {
|
|
87
|
+
filename: "test.csv",
|
|
88
|
+
hash: "hash",
|
|
89
|
+
latest_status: "COMPLETED",
|
|
90
|
+
latest_event_at: "2023-01-01T00:00:00Z",
|
|
91
|
+
latest_event_response: {},
|
|
92
|
+
events: [{ status: "COMPLETED", response: {} }],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const rendered = render(<UploadJobs scope="implementations" />, {
|
|
98
|
+
messages,
|
|
99
|
+
});
|
|
100
|
+
await waitForLoad(rendered);
|
|
101
|
+
await user.click(rendered.getByText("test.csv"));
|
|
102
|
+
await user.click(
|
|
103
|
+
rendered.getByRole("button", { name: /^Processing results/i }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(document.querySelector(".upload-job-table-wrap")).toHaveClass(
|
|
107
|
+
"upload-job-table-wrap-constrained",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each([
|
|
112
|
+
["Enter", "{Enter}"],
|
|
113
|
+
["Space", " "],
|
|
114
|
+
])(
|
|
115
|
+
"opens the modal via keyboard (%s) and closes it clearing the selection",
|
|
116
|
+
async (_, key) => {
|
|
117
|
+
const user = userEvent.setup({ delay: null });
|
|
118
|
+
useUploadJobs.mockReturnValue({
|
|
119
|
+
loading: false,
|
|
120
|
+
data: { data: mockJobs },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const rendered = render(<UploadJobs scope="implementations" />, {
|
|
124
|
+
messages,
|
|
125
|
+
});
|
|
126
|
+
await waitForLoad(rendered);
|
|
127
|
+
|
|
128
|
+
const row = rendered.getByText("test.csv").closest("tr");
|
|
129
|
+
row.focus();
|
|
130
|
+
await user.keyboard(key);
|
|
131
|
+
|
|
132
|
+
expect(rendered.getByRole("dialog")).toBeInTheDocument();
|
|
133
|
+
|
|
134
|
+
await user.click(rendered.getByRole("button", { name: /close/i }));
|
|
135
|
+
|
|
136
|
+
expect(rendered.queryByRole("dialog")).not.toBeInTheDocument();
|
|
137
|
+
expect(useUploadJob).toHaveBeenLastCalledWith(1);
|
|
138
|
+
expect(useUploadJob).not.toHaveBeenCalledWith(null);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
10
142
|
it("renders loading state", async () => {
|
|
11
143
|
useUploadJobs.mockReturnValue({
|
|
12
144
|
loading: true,
|