@truedat/core 8.13.0 → 8.13.2

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.
@@ -1,9 +1,58 @@
1
1
  import userEvent from "@testing-library/user-event";
2
2
  import { renderWithIntl as render } from "@truedat/test/renderWithIntl";
3
- import { StatusPill, ResponseCell } from "../UploadJobParser";
3
+ import {
4
+ ResultDetailCell,
5
+ ResultEntityCell,
6
+ StatusPill,
7
+ ResponseCell,
8
+ } from "../UploadJobParser";
4
9
 
5
- jest.mock("@truedat/dq/components/RuleImplementationLink", () => () => null);
6
- jest.mock("@truedat/dd/components/StructureNoteLink", () => () => null);
10
+ jest.mock("@truedat/dq/components/RuleImplementationLink", () => ({
11
+ __esModule: true,
12
+ default: ({ implementation_key, implementation_ref }) => (
13
+ <>
14
+ <a href={`/implementations/${implementation_key}`}>
15
+ {implementation_key}
16
+ </a>
17
+ {implementation_ref != null && implementation_ref !== "" ? (
18
+ <small>{implementation_ref}</small>
19
+ ) : null}
20
+ </>
21
+ ),
22
+ }));
23
+ jest.mock("@truedat/dd/components/StructureNoteLink", () => ({
24
+ __esModule: true,
25
+ default: ({ alias, external_id, name }) => {
26
+ const label = alias?.trim() || name || external_id;
27
+ return (
28
+ <>
29
+ <a href={`/notes/${external_id}`}>{label}</a>
30
+ {external_id !== label ? <small>{external_id}</small> : null}
31
+ </>
32
+ );
33
+ },
34
+ }));
35
+
36
+ const changeMessages = {
37
+ en: {
38
+ "uploadJob.parser.info.implementation_updated": "Item updated",
39
+ "uploadJob.parser.info.updated": "Updated",
40
+ "uploadJob.results.changes.modifiedFields":
41
+ "{count, plural, one {# modified field} other {# modified fields}}",
42
+ "uploadJob.results.changes.field": "Field",
43
+ "uploadJob.results.changes.previous": "Previous value",
44
+ "uploadJob.results.changes.change": "Change",
45
+ "uploadJob.results.changes.new": "New value",
46
+ "uploadJob.results.errors.errorFields":
47
+ "{count, plural, one {# field with an error} other {# fields with errors}}",
48
+ "uploadJob.results.errors.field": "Field",
49
+ "uploadJob.results.errors.error": "Error",
50
+ "uploadJob.parser.error.field_validation_error": "Field validation error",
51
+ "uploadJob.parser.error.data_structure_not_found":
52
+ "Data structure not found",
53
+ "uploadJob.parser.detail.message": "Message",
54
+ },
55
+ };
7
56
 
8
57
  describe("<StatusPill />", () => {
9
58
  it("renders with correct color based on status", async () => {
@@ -23,6 +72,174 @@ describe("<StatusPill />", () => {
23
72
  });
24
73
  });
25
74
 
75
+ describe("<ResultEntityCell />", () => {
76
+ it("renders INFO implementation and structure-note links from row details", () => {
77
+ const implementation = render(
78
+ <ResultEntityCell
79
+ status="INFO"
80
+ response={{ details: { implementation_key: "impl" } }}
81
+ />,
82
+ );
83
+ expect(implementation.getByRole("link", { name: "impl" })).toHaveAttribute(
84
+ "href",
85
+ "/implementations/impl",
86
+ );
87
+
88
+ const note = render(
89
+ <ResultEntityCell
90
+ status="INFO"
91
+ response={{
92
+ details: { data_structure_id: 1, external_id: "note" },
93
+ }}
94
+ />,
95
+ );
96
+ expect(note.getByRole("link", { name: "note" })).toHaveAttribute(
97
+ "href",
98
+ "/notes/note",
99
+ );
100
+ });
101
+
102
+ it("renders implementation refs and preferred structure labels with external ids", () => {
103
+ const implementation = render(
104
+ <ResultEntityCell
105
+ status="INFO"
106
+ response={{
107
+ details: { implementation_key: "impl", implementation_ref: 0 },
108
+ }}
109
+ />,
110
+ );
111
+ expect(implementation.getByText("0")).toBeInTheDocument();
112
+
113
+ const alias = render(
114
+ <ResultEntityCell
115
+ status="INFO"
116
+ response={{
117
+ details: {
118
+ alias: "Structure alias",
119
+ data_structure_id: 1,
120
+ external_id: "EXT-001",
121
+ name: "Structure name",
122
+ },
123
+ }}
124
+ />,
125
+ );
126
+ expect(
127
+ alias.getByRole("link", { name: "Structure alias" }),
128
+ ).toHaveAttribute("href", "/notes/EXT-001");
129
+ expect(alias.getByText("EXT-001")).toBeInTheDocument();
130
+
131
+ const name = render(
132
+ <ResultEntityCell
133
+ status="INFO"
134
+ response={{
135
+ details: {
136
+ alias: " ",
137
+ data_structure_id: 1,
138
+ external_id: "EXT-002",
139
+ name: "Structure name",
140
+ },
141
+ }}
142
+ />,
143
+ );
144
+ expect(name.getByRole("link", { name: "Structure name" })).toHaveAttribute(
145
+ "href",
146
+ "/notes/EXT-002",
147
+ );
148
+ expect(name.getByText("EXT-002")).toBeInTheDocument();
149
+ });
150
+
151
+ it("renders a structure-note link for errors with identified structures", () => {
152
+ const rendered = render(
153
+ <ResultEntityCell
154
+ status="ERROR"
155
+ response={{
156
+ type: "field_validation_error",
157
+ details: { data_structure_id: 1, external_id: "note" },
158
+ }}
159
+ />,
160
+ );
161
+
162
+ expect(rendered.getByRole("link", { name: "note" })).toHaveAttribute(
163
+ "href",
164
+ "/notes/note",
165
+ );
166
+ });
167
+
168
+ it("renders an implementation link for errors with identified implementations", () => {
169
+ const rendered = render(
170
+ <ResultEntityCell
171
+ status="ERROR"
172
+ response={{ details: { implementation_key: "impl" } }}
173
+ />,
174
+ );
175
+
176
+ expect(rendered.getByRole("link", { name: "impl" })).toHaveAttribute(
177
+ "href",
178
+ "/implementations/impl",
179
+ );
180
+ });
181
+
182
+ it("does not render an entity for data_structure_not_found errors", () => {
183
+ const rendered = render(
184
+ <ResultEntityCell
185
+ status="ERROR"
186
+ response={{
187
+ type: "data_structure_not_found",
188
+ details: {
189
+ data_structure_id: 1,
190
+ external_id: "unknown-note",
191
+ implementation_key: "impl",
192
+ },
193
+ }}
194
+ />,
195
+ );
196
+
197
+ expect(rendered.container.firstChild).toBeNull();
198
+ });
199
+
200
+ it.each([{ data_structure_id: 1 }, { external_id: "note" }])(
201
+ "does not render ERROR entities with incomplete structure identifiers",
202
+ (details) => {
203
+ const rendered = render(
204
+ <ResultEntityCell
205
+ status="ERROR"
206
+ response={{ type: "field_validation_error", details }}
207
+ />,
208
+ );
209
+
210
+ expect(rendered.container.firstChild).toBeNull();
211
+ },
212
+ );
213
+
214
+ it("does not render an entity for errors without reliable identifiers", () => {
215
+ const rendered = render(
216
+ <ResultEntityCell
217
+ status="ERROR"
218
+ response={{ type: "field_validation_error", details: {} }}
219
+ />,
220
+ );
221
+
222
+ expect(rendered.container.firstChild).toBeNull();
223
+ });
224
+
225
+ it("does not render an entity for non-result statuses", () => {
226
+ const rendered = render(
227
+ <ResultEntityCell
228
+ status="FAILED"
229
+ response={{
230
+ details: {
231
+ data_structure_id: 1,
232
+ external_id: "note",
233
+ implementation_key: "impl",
234
+ },
235
+ }}
236
+ />,
237
+ );
238
+
239
+ expect(rendered.container.firstChild).toBeNull();
240
+ });
241
+ });
242
+
26
243
  describe("<ResponseCell />", () => {
27
244
  it("returns null when response is empty", () => {
28
245
  const rendered = render(
@@ -34,12 +251,66 @@ describe("<ResponseCell />", () => {
34
251
  expect(rendered2.container.firstChild).toBeNull();
35
252
  });
36
253
 
37
- it("renders FAILED status with message", () => {
38
- const response = { message: "missing_required_headers" };
254
+ it("renders FAILED status with a translated error code", () => {
255
+ const response = { message: "invalid_format" };
39
256
  const rendered = render(
40
257
  <ResponseCell response={response} status="FAILED" />,
258
+ {
259
+ messages: {
260
+ en: {
261
+ "uploadJob.parser.error.invalid_format": "Invalid file format",
262
+ },
263
+ },
264
+ },
265
+ );
266
+ expect(rendered.getByText("Invalid file format")).toBeInTheDocument();
267
+ });
268
+
269
+ it("replaces FAILED technical exception messages with an unexpected error", () => {
270
+ const technicalMessage = '%RuntimeError{message: "boom"}';
271
+ const rendered = render(
272
+ <ResponseCell response={{ message: technicalMessage }} status="FAILED" />,
273
+ {
274
+ messages: {
275
+ en: {
276
+ "uploadJob.parser.error.unexpected_error": "Unexpected error",
277
+ },
278
+ },
279
+ },
280
+ );
281
+
282
+ expect(rendered.getByText("Unexpected error")).toBeInTheDocument();
283
+ expect(rendered.queryByText(technicalMessage)).not.toBeInTheDocument();
284
+ });
285
+
286
+ it("preserves FAILED error codes and human messages", () => {
287
+ const code = render(
288
+ <ResponseCell
289
+ response={{ message: "missing_required_headers" }}
290
+ status="FAILED"
291
+ />,
292
+ );
293
+ expect(code.getByText(/missing_required_headers/i)).toBeInTheDocument();
294
+
295
+ const human = render(
296
+ <ResponseCell
297
+ response={{ message: "The file could not be processed" }}
298
+ status="FAILED"
299
+ />,
300
+ );
301
+ expect(
302
+ human.getByText("The file could not be processed"),
303
+ ).toBeInTheDocument();
304
+
305
+ const multiline = render(
306
+ <ResponseCell
307
+ response={{ message: "The file could not be processed\nPlease retry" }}
308
+ status="FAILED"
309
+ />,
310
+ );
311
+ expect(multiline.container).toHaveTextContent(
312
+ "The file could not be processed Please retry",
41
313
  );
42
- expect(rendered.getByText(/missing_required_headers/i)).toBeInTheDocument();
43
314
  });
44
315
 
45
316
  it("renders ERROR status with details", () => {
@@ -85,10 +356,13 @@ describe("<ResponseCell />", () => {
85
356
  },
86
357
  },
87
358
  };
88
- const rendered = render(<ResponseCell response={response} status="INFO" />);
89
- expect(
90
- rendered.getByText(/uploadJob.parser.info.implementation_updated/i),
91
- ).toBeInTheDocument();
359
+ const rendered = render(
360
+ <ResponseCell response={response} status="INFO" />,
361
+ {
362
+ messages: changeMessages,
363
+ },
364
+ );
365
+ expect(rendered.getByText("Item updated")).toBeInTheDocument();
92
366
  });
93
367
 
94
368
  it("renders INFO status with deprecated_implementation specifically when unchanged and deprecated", () => {
@@ -99,7 +373,12 @@ describe("<ResponseCell />", () => {
99
373
  is_deprecated: true,
100
374
  },
101
375
  };
102
- const rendered = render(<ResponseCell response={response} status="INFO" />);
376
+ const rendered = render(
377
+ <ResponseCell response={response} status="INFO" />,
378
+ {
379
+ messages: changeMessages,
380
+ },
381
+ );
103
382
  expect(
104
383
  rendered.getByText(/uploadJob.parser.info.deprecated_implementation/i),
105
384
  ).toBeInTheDocument();
@@ -306,23 +585,38 @@ describe("<ResponseCell />", () => {
306
585
  },
307
586
  };
308
587
  const user = userEvent.setup({ delay: null });
309
- const rendered = render(<ResponseCell response={response} status="INFO" />);
310
- expect(
311
- rendered.getByText(/uploadJob.parser.info.implementation_updated/i),
312
- ).toBeInTheDocument();
588
+ const rendered = render(
589
+ <ResponseCell response={response} status="INFO" />,
590
+ {
591
+ messages: changeMessages,
592
+ },
593
+ );
313
594
  expect(
314
- rendered.getByText(/uploadJob.parser.result.prop.changes/i),
595
+ rendered.getByRole("button", { name: /2 modified fields/i }),
315
596
  ).toBeInTheDocument();
316
- expect(rendered.getByText(/\(2\)/)).toBeInTheDocument();
317
597
  await user.click(
318
- rendered
319
- .getByText(/uploadJob.parser.result.prop.changes/i)
320
- .closest("div"),
598
+ rendered.getByRole("button", { name: /2 modified fields/i }),
321
599
  );
322
600
  expect(rendered.getByText(/New Name/i)).toBeInTheDocument();
323
601
  expect(rendered.getByText(/Updated description/i)).toBeInTheDocument();
324
602
  });
325
603
 
604
+ it("does not render changes for created INFO events", () => {
605
+ const rendered = render(
606
+ <ResultDetailCell
607
+ response={{
608
+ type: "created",
609
+ details: { changes: { name: "Created name" } },
610
+ }}
611
+ status="INFO"
612
+ />,
613
+ { messages: changeMessages },
614
+ );
615
+
616
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
617
+ expect(rendered.queryByRole("table")).not.toBeInTheDocument();
618
+ });
619
+
326
620
  it("renders INFO status with nested df_content in changes", async () => {
327
621
  const response = {
328
622
  type: "implementation_updated",
@@ -335,14 +629,118 @@ describe("<ResponseCell />", () => {
335
629
  },
336
630
  };
337
631
  const user = userEvent.setup({ delay: null });
338
- const rendered = render(<ResponseCell response={response} status="INFO" />);
339
- const changesHeader = rendered.getByText(
340
- /uploadJob.parser.result.prop.changes/i,
632
+ const rendered = render(
633
+ <ResponseCell response={response} status="INFO" />,
634
+ {
635
+ messages: changeMessages,
636
+ },
637
+ );
638
+ await user.click(
639
+ rendered.getByRole("button", { name: /1 modified field/i }),
640
+ );
641
+ expect(rendered.getByText(/inner_key/i)).toBeInTheDocument();
642
+ });
643
+
644
+ it("renders change_details as a translated old/new values table", async () => {
645
+ const response = {
646
+ type: "updated",
647
+ details: {
648
+ change_details: {
649
+ goal: { old: 80, new: 90 },
650
+ df_content: {
651
+ frequency: {
652
+ old: {
653
+ value: "a very long previous value that must remain accessible",
654
+ origin: "user",
655
+ },
656
+ new: {
657
+ value: "a very long new value that must remain accessible",
658
+ origin: "file",
659
+ },
660
+ },
661
+ },
662
+ },
663
+ },
664
+ };
665
+ const user = userEvent.setup({ delay: null });
666
+ const rendered = render(
667
+ <ResponseCell response={response} status="INFO" />,
668
+ {
669
+ messages: changeMessages,
670
+ },
341
671
  );
342
- await user.click(changesHeader.closest("div"));
672
+
673
+ const toggle = rendered.getByRole("button", {
674
+ name: /2 modified fields/i,
675
+ });
676
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
677
+ expect(toggle).toHaveAttribute("aria-controls");
678
+ await user.click(toggle);
679
+
680
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
681
+ expect(rendered.getByText("Field")).toBeInTheDocument();
682
+ expect(rendered.getByText("Previous value")).toBeInTheDocument();
683
+ expect(rendered.getByText("New value")).toBeInTheDocument();
684
+ expect(rendered.getByText("80")).toBeInTheDocument();
685
+ expect(rendered.getByText("90")).toBeInTheDocument();
343
686
  expect(
344
- rendered.getByText(/ruleImplementations.props.df_content/i),
345
- ).toBeInTheDocument();
687
+ rendered.getByText(
688
+ "a very long previous value that must remain accessible",
689
+ ),
690
+ ).toHaveAttribute(
691
+ "title",
692
+ "a very long previous value that must remain accessible",
693
+ );
694
+ expect(
695
+ rendered.getByText("a very long new value that must remain accessible"),
696
+ ).toHaveAttribute(
697
+ "title",
698
+ "a very long new value that must remain accessible",
699
+ );
700
+ const arrows = rendered.getAllByText("→");
701
+ expect(arrows).toHaveLength(2);
702
+ arrows.forEach((arrow) => {
703
+ expect(arrow).toHaveAttribute("aria-hidden", "true");
704
+ expect(arrow.closest("span")).toHaveAttribute("aria-hidden", "true");
705
+ expect(arrow.closest("td")).not.toHaveAttribute("aria-hidden");
706
+ });
707
+ });
708
+
709
+ it("renders boolean, null and zero change values without losing their values", async () => {
710
+ const user = userEvent.setup({ delay: null });
711
+ const rendered = render(
712
+ <ResponseCell
713
+ response={{
714
+ type: "updated",
715
+ details: {
716
+ change_details: {
717
+ enabled: { old: false, new: true },
718
+ nullable: { old: null, new: null },
719
+ count: { old: 0, new: 0 },
720
+ },
721
+ },
722
+ }}
723
+ status="INFO"
724
+ />,
725
+ { messages: changeMessages },
726
+ );
727
+
728
+ await user.click(
729
+ rendered.getByRole("button", { name: /3 modified fields/i }),
730
+ );
731
+
732
+ const falseValue = rendered.getByText("false");
733
+ const trueValue = rendered.getByText("true");
734
+ const emptyValues = rendered.getAllByText("—");
735
+ const zeroValues = rendered.getAllByText("0");
736
+
737
+ expect(falseValue).toHaveAttribute("title", "false");
738
+ expect(trueValue).toHaveAttribute("title", "true");
739
+ expect(emptyValues).toHaveLength(2);
740
+ expect(emptyValues[0]).toHaveAttribute("title", "—");
741
+ expect(zeroValues).toHaveLength(2);
742
+ expect(zeroValues[0]).toHaveAttribute("title", "0");
743
+ expect(falseValue).not.toHaveAttribute("aria-label");
346
744
  });
347
745
 
348
746
  it("renders ERROR with sheet and row_number in response", () => {
@@ -372,3 +770,139 @@ describe("<ResponseCell />", () => {
372
770
  expect(rendered.container.firstChild).toBeNull();
373
771
  });
374
772
  });
773
+
774
+ describe("<ResultDetailCell /> errors", () => {
775
+ const response = {
776
+ type: "field_validation_error",
777
+ sheet: "Sheet1",
778
+ row_number: 5,
779
+ details: {
780
+ errors: [
781
+ { field: "name", message: "Required" },
782
+ { field: "external_id", message: "Already exists" },
783
+ ],
784
+ },
785
+ };
786
+
787
+ it("renders structured errors collapsed and expands them as a Field/Error table", async () => {
788
+ const user = userEvent.setup({ delay: null });
789
+ const rendered = render(
790
+ <ResultDetailCell response={response} status="ERROR" />,
791
+ { messages: changeMessages },
792
+ );
793
+
794
+ const toggle = rendered.getByRole("button", {
795
+ name: "2 fields with errors",
796
+ });
797
+ expect(toggle).not.toHaveAccessibleName(/field validation error/i);
798
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
799
+ expect(toggle).toHaveAttribute("aria-controls");
800
+ expect(rendered.queryByText("Required")).not.toBeInTheDocument();
801
+ expect(rendered.queryByText("Sheet1")).not.toBeInTheDocument();
802
+
803
+ await user.click(toggle);
804
+
805
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
806
+ expect(
807
+ rendered.getByRole("columnheader", { name: "Field" }),
808
+ ).toBeInTheDocument();
809
+ expect(
810
+ rendered.getByRole("columnheader", { name: "Error" }),
811
+ ).toBeInTheDocument();
812
+ expect(
813
+ rendered.getByRole("rowheader", { name: "name" }),
814
+ ).toBeInTheDocument();
815
+ expect(
816
+ rendered.getByRole("rowheader", { name: "external_id" }),
817
+ ).toBeInTheDocument();
818
+ expect(rendered.getByText("Required")).toBeInTheDocument();
819
+ expect(rendered.getByText("Already exists")).toBeInTheDocument();
820
+ });
821
+
822
+ it("does not render an empty disclosure when the error has no details", () => {
823
+ const rendered = render(
824
+ <ResultDetailCell
825
+ response={{ type: "field_validation_error" }}
826
+ status="ERROR"
827
+ />,
828
+ { messages: changeMessages },
829
+ );
830
+
831
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
832
+ expect(rendered.getByText("Field validation error")).toBeInTheDocument();
833
+ });
834
+
835
+ it("renders data_structure_not_found as a simple message without technical details", () => {
836
+ const rendered = render(
837
+ <ResultDetailCell
838
+ response={{
839
+ type: "data_structure_not_found",
840
+ details: { external_id: "unknown-note", data_structure_id: 123 },
841
+ }}
842
+ status="ERROR"
843
+ />,
844
+ { messages: changeMessages },
845
+ );
846
+
847
+ expect(rendered.getByText("Data structure not found")).toBeInTheDocument();
848
+ expect(rendered.queryByRole("button")).not.toBeInTheDocument();
849
+ expect(rendered.queryByRole("table")).not.toBeInTheDocument();
850
+ expect(rendered.queryByText("unknown-note")).not.toBeInTheDocument();
851
+ });
852
+
853
+ it("uses the singular error count for one field", () => {
854
+ const rendered = render(
855
+ <ResultDetailCell
856
+ response={{
857
+ type: "field_validation_error",
858
+ details: { errors: [{ field: "name", message: "Required" }] },
859
+ }}
860
+ status="ERROR"
861
+ />,
862
+ { messages: changeMessages },
863
+ );
864
+
865
+ expect(
866
+ rendered.getByRole("button", {
867
+ name: "1 field with an error",
868
+ }),
869
+ ).toBeInTheDocument();
870
+ });
871
+
872
+ it("renders notes change_details with previous and new values", async () => {
873
+ const user = userEvent.setup({ delay: null });
874
+ const rendered = render(
875
+ <ResultDetailCell
876
+ response={{
877
+ type: "updated",
878
+ details: {
879
+ external_id: "EXT-001",
880
+ data_structure_id: 123,
881
+ changes: { status: "published" },
882
+ change_details: {
883
+ status: { old: "draft", new: "published" },
884
+ df_content: {
885
+ field: {
886
+ old: { value: "old_value", origin: "user" },
887
+ new: { value: "new_value", origin: "file" },
888
+ },
889
+ },
890
+ },
891
+ },
892
+ }}
893
+ status="INFO"
894
+ scope="notes"
895
+ />,
896
+ { messages: changeMessages },
897
+ );
898
+
899
+ await user.click(
900
+ rendered.getByRole("button", { name: /2 modified fields/i }),
901
+ );
902
+
903
+ expect(rendered.getByText("draft")).toBeInTheDocument();
904
+ expect(rendered.getByText("published")).toBeInTheDocument();
905
+ expect(rendered.getByText("old_value")).toBeInTheDocument();
906
+ expect(rendered.getByText("new_value")).toBeInTheDocument();
907
+ });
908
+ });