@truedat/lm 8.7.4 → 8.8.0

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.
Files changed (26) hide show
  1. package/package.json +3 -3
  2. package/src/components/ConfirmDeleteRelation.js +14 -2
  3. package/src/components/LinksPane.js +116 -88
  4. package/src/components/LinksPaneDataCell.js +72 -0
  5. package/src/components/LinksPaneRow.js +18 -0
  6. package/src/components/LinksPaneTypeFilter.js +82 -0
  7. package/src/components/__tests__/ConfirmDeleteRelation.spec.js +9 -0
  8. package/src/components/__tests__/LinksPane.spec.js +60 -95
  9. package/src/components/__tests__/LinksPaneDataCell.spec.js +192 -0
  10. package/src/components/__tests__/LinksPaneRow.spec.js +94 -0
  11. package/src/components/__tests__/LinksPaneTypeFilter.spec.js +164 -0
  12. package/src/components/__tests__/__snapshots__/ConfirmDeleteRelation.spec.js.snap +2 -2
  13. package/src/components/__tests__/__snapshots__/ImplementationLinks.spec.js.snap +4 -4
  14. package/src/components/__tests__/__snapshots__/LinksPane.spec.js.snap +98 -40
  15. package/src/hooks/__tests__/useFilteredLinks.spec.js +116 -0
  16. package/src/hooks/__tests__/useSortedLinks.spec.js +129 -0
  17. package/src/hooks/__tests__/useStructureNotes.spec.js +138 -0
  18. package/src/hooks/__tests__/useTruncatedPopup.spec.js +109 -0
  19. package/src/hooks/useFilteredLinks.js +98 -0
  20. package/src/hooks/useSortedLinks.js +8 -0
  21. package/src/hooks/useStructureNotes.js +52 -0
  22. package/src/hooks/useTruncatedPopup.js +30 -0
  23. package/src/styles/LinksPane.less +117 -0
  24. package/src/styles/RelationActions.less +7 -0
  25. package/src/utils/__tests__/linksPaneHelpers.spec.js +316 -0
  26. package/src/utils/linksPaneHelpers.js +164 -0
@@ -92,11 +92,11 @@ exports[`<ImplementationLinks /> matches the latest snapshot 1`] = `
92
92
  class=""
93
93
  >
94
94
  <button
95
- class="ui red mini basic icon button"
95
+ class="ui red mini basic compact icon button relation-actions__delete-button"
96
96
  >
97
97
  <i
98
98
  aria-hidden="true"
99
- class="red trash alternate outline icon"
99
+ class="red trash alternate outline small icon relation-actions__delete-icon"
100
100
  />
101
101
  </button>
102
102
  </td>
@@ -185,11 +185,11 @@ exports[`<ImplementationLinks /> matches the latest snapshot when implementation
185
185
  class=""
186
186
  >
187
187
  <button
188
- class="ui red mini basic icon button"
188
+ class="ui red mini basic compact icon button relation-actions__delete-button"
189
189
  >
190
190
  <i
191
191
  aria-hidden="true"
192
- class="red trash alternate outline icon"
192
+ class="red trash alternate outline small icon relation-actions__delete-icon"
193
193
  />
194
194
  </button>
195
195
  </td>
@@ -27,24 +27,30 @@ exports[`<LinksPane /> matches the latest snapshot 1`] = `
27
27
  >
28
28
  Master title
29
29
  </h3>
30
- <table
31
- class="ui table"
30
+ <div
31
+ class="links-pane__table-scroll"
32
32
  >
33
- <thead
34
- class=""
33
+ <table
34
+ class="ui celled sortable unstackable compact table links-pane__table"
35
+ style="--links-pane-table-min-width: 0px;"
35
36
  >
36
- <tr
37
+ <colgroup />
38
+ <thead
37
39
  class=""
38
- />
39
- </thead>
40
- <tbody
41
- class=""
42
- >
43
- <tr
40
+ >
41
+ <tr
42
+ class=""
43
+ />
44
+ </thead>
45
+ <tbody
44
46
  class=""
45
- />
46
- </tbody>
47
- </table>
47
+ >
48
+ <tr
49
+ class=""
50
+ />
51
+ </tbody>
52
+ </table>
53
+ </div>
48
54
  </div>
49
55
  </div>
50
56
  </div>
@@ -78,43 +84,95 @@ exports[`<LinksPane /> should display note columns only when note values are pre
78
84
  >
79
85
  Master title
80
86
  </h3>
81
- <table
82
- class="ui table"
87
+ <div
88
+ class="links-pane__table-scroll"
83
89
  >
84
- <thead
85
- class=""
90
+ <table
91
+ class="ui celled sortable unstackable compact table links-pane__table"
92
+ style="--links-pane-table-min-width: 384px;"
86
93
  >
87
- <tr
94
+ <colgroup>
95
+ <col
96
+ class="links-pane__column"
97
+ style="--links-pane-column-width: 264px;"
98
+ />
99
+ <col
100
+ class="links-pane__column"
101
+ style="--links-pane-column-width: 120px;"
102
+ />
103
+ </colgroup>
104
+ <thead
88
105
  class=""
89
106
  >
90
- <th
107
+ <tr
91
108
  class=""
92
109
  >
93
- Foo
94
- </th>
95
- </tr>
96
- </thead>
97
- <tbody
98
- class=""
99
- >
100
- <tr
110
+ <th
111
+ class="links-pane__cell links-pane__cell--header links-pane__cell--wrap"
112
+ style="--links-pane-column-width: 264px;"
113
+ >
114
+ <span
115
+ class="links-pane__filter-label"
116
+ >
117
+ <span
118
+ class="links-pane__filter-label-text"
119
+ >
120
+ Name
121
+ </span>
122
+ </span>
123
+ </th>
124
+ <th
125
+ class="links-pane__cell links-pane__cell--header"
126
+ style="--links-pane-column-width: 120px;"
127
+ >
128
+ <span
129
+ class="links-pane__filter-label"
130
+ >
131
+ <span
132
+ class="links-pane__filter-label-text"
133
+ >
134
+ Foo
135
+ </span>
136
+ </span>
137
+ </th>
138
+ </tr>
139
+ </thead>
140
+ <tbody
101
141
  class=""
102
142
  >
103
- <td
143
+ <tr
104
144
  class=""
105
145
  >
106
- baz
107
- </td>
108
- </tr>
109
- <tr
110
- class=""
111
- >
112
- <td
146
+ <td
147
+ class="links-pane__cell links-pane__cell--wrap"
148
+ style="--links-pane-column-width: 264px;"
149
+ >
150
+ Structure 1
151
+ </td>
152
+ <td
153
+ class="links-pane__cell"
154
+ style="--links-pane-column-width: 120px;"
155
+ >
156
+ baz
157
+ </td>
158
+ </tr>
159
+ <tr
113
160
  class=""
114
- />
115
- </tr>
116
- </tbody>
117
- </table>
161
+ >
162
+ <td
163
+ class="links-pane__cell links-pane__cell--wrap"
164
+ style="--links-pane-column-width: 264px;"
165
+ >
166
+ Structure 2
167
+ </td>
168
+ <td
169
+ class="links-pane__cell"
170
+ style="--links-pane-column-width: 120px;"
171
+ />
172
+ </tr>
173
+ </tbody>
174
+ </table>
175
+ </div>
118
176
  </div>
119
177
  </div>
120
178
  </div>
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { renderHook } from "@testing-library/react";
6
+ import { useFilteredLinks } from "../useFilteredLinks";
7
+
8
+ jest.mock(
9
+ "@truedat/core/services",
10
+ () => {
11
+ const lodashFp = require("lodash/fp");
12
+ return {
13
+ columnDecorator: () => () => null,
14
+ columnPredicate: () => () => true,
15
+ };
16
+ },
17
+ { virtual: true },
18
+ );
19
+
20
+ describe("useFilteredLinks", () => {
21
+ const enrichedLinks = [
22
+ { id: 1, name: "Alpha", tags: ["main"], type: "column" },
23
+ { id: 2, name: "Beta", tags: ["secondary"], type: "table" },
24
+ { id: 3, name: "Gamma", tags: ["main"], type: "field" },
25
+ { id: 4, name: "Delta", tags: ["secondary"], type: "column" },
26
+ ];
27
+
28
+ it("returns all links when no filters are selected", () => {
29
+ const { result } = renderHook(() =>
30
+ useFilteredLinks({
31
+ enrichedLinks,
32
+ selectedTypes: [],
33
+ selectedRelationTypes: [],
34
+ }),
35
+ );
36
+
37
+ expect(result.current.filteredLinks).toHaveLength(4);
38
+ expect(result.current.typeOptions).toEqual(["column", "field", "table"]);
39
+ expect(result.current.relationTypeOptions).toEqual(["main", "secondary"]);
40
+ });
41
+
42
+ it("filters links by type", () => {
43
+ const { result } = renderHook(() =>
44
+ useFilteredLinks({
45
+ enrichedLinks,
46
+ selectedTypes: ["column"],
47
+ selectedRelationTypes: [],
48
+ }),
49
+ );
50
+
51
+ expect(result.current.filteredLinks).toHaveLength(2);
52
+ expect(result.current.filteredLinks.map((l) => l.name)).toEqual([
53
+ "Alpha",
54
+ "Delta",
55
+ ]);
56
+ });
57
+
58
+ it("filters links by relation type", () => {
59
+ const { result } = renderHook(() =>
60
+ useFilteredLinks({
61
+ enrichedLinks,
62
+ selectedTypes: [],
63
+ selectedRelationTypes: ["main"],
64
+ }),
65
+ );
66
+
67
+ expect(result.current.filteredLinks).toHaveLength(2);
68
+ expect(result.current.filteredLinks.map((l) => l.name)).toEqual([
69
+ "Alpha",
70
+ "Gamma",
71
+ ]);
72
+ });
73
+
74
+ it("combines type and relation type filters", () => {
75
+ const { result } = renderHook(() =>
76
+ useFilteredLinks({
77
+ enrichedLinks,
78
+ selectedTypes: ["column"],
79
+ selectedRelationTypes: ["main"],
80
+ }),
81
+ );
82
+
83
+ expect(result.current.filteredLinks).toHaveLength(1);
84
+ expect(result.current.filteredLinks.map((l) => l.name)).toEqual(["Alpha"]);
85
+ });
86
+
87
+ it("includes empty option for links without tags", () => {
88
+ const linksWithEmpty = [
89
+ { id: 1, name: "Alpha", tags: ["main"] },
90
+ { id: 2, name: "Beta", tags: [] },
91
+ { id: 3, name: "Gamma" },
92
+ ];
93
+
94
+ const { result } = renderHook(() =>
95
+ useFilteredLinks({
96
+ enrichedLinks: linksWithEmpty,
97
+ selectedTypes: [],
98
+ selectedRelationTypes: [],
99
+ }),
100
+ );
101
+
102
+ expect(result.current.relationTypeOptions).toEqual(["", "main"]);
103
+ });
104
+
105
+ it("updates typeOptions based on relationType filter", () => {
106
+ const { result } = renderHook(() =>
107
+ useFilteredLinks({
108
+ enrichedLinks,
109
+ selectedTypes: [],
110
+ selectedRelationTypes: ["secondary"],
111
+ }),
112
+ );
113
+
114
+ expect(result.current.typeOptions).toEqual(["column", "table"]);
115
+ });
116
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { renderHook } from "@testing-library/react";
6
+ import { useSortedLinks } from "../useSortedLinks";
7
+
8
+ jest.mock(
9
+ "@truedat/core/services",
10
+ () => {
11
+ const lodashFp = require("lodash/fp");
12
+ return {
13
+ columnDecorator: (column) => (obj) => {
14
+ const { fieldSelector, fieldDecorator, name } = column || {};
15
+ const field = lodashFp.isFunction(fieldSelector)
16
+ ? fieldSelector(obj)
17
+ : lodashFp.path(lodashFp.defaultTo(name)(fieldSelector))(obj);
18
+ return lodashFp.isFunction(fieldDecorator)
19
+ ? fieldDecorator(field)
20
+ : field;
21
+ },
22
+ columnPredicate: () => () => true,
23
+ };
24
+ },
25
+ { virtual: true },
26
+ );
27
+
28
+ describe("useSortedLinks", () => {
29
+ const columns = [
30
+ { name: "name" },
31
+ { name: "relation.relation_type_name", fieldSelector: (link) => link.tags },
32
+ { name: "type" },
33
+ ];
34
+
35
+ const links = [
36
+ { id: 1, name: "Zulu", tags: ["secondary"], type: "field" },
37
+ { id: 2, name: "Alpha", tags: ["main"], type: "table" },
38
+ { id: 3, name: "Beta", tags: ["main"], type: "column" },
39
+ ];
40
+
41
+ it("sorts by relation type then type when no sort column", () => {
42
+ const sort = { column: null, direction: "ascending" };
43
+
44
+ const { result } = renderHook(() => useSortedLinks(links, columns, sort));
45
+
46
+ expect(result.current.map((l) => l.name)).toEqual([
47
+ "Beta",
48
+ "Alpha",
49
+ "Zulu",
50
+ ]);
51
+ });
52
+
53
+ it("sorts by type when relation type column is not present", () => {
54
+ const typeOnlyColumns = [{ name: "name" }, { name: "type" }];
55
+ const typeLinks = [
56
+ { id: 1, name: "Zulu", type: "table" },
57
+ { id: 2, name: "Alpha", type: "column" },
58
+ ];
59
+ const sort = { column: null, direction: "ascending" };
60
+
61
+ const { result } = renderHook(() =>
62
+ useSortedLinks(typeLinks, typeOnlyColumns, sort),
63
+ );
64
+
65
+ expect(result.current.map((l) => l.name)).toEqual(["Alpha", "Zulu"]);
66
+ });
67
+
68
+ it("sorts by default fallback when no sortable columns exist", () => {
69
+ const noSortColumns = [{ name: "_actions" }];
70
+ const fallbackLinks = [
71
+ { id: 1, business_concept_id: 10, name: "Beta" },
72
+ { id: 2, business_concept_id: 5, name: "Alpha" },
73
+ ];
74
+ const sort = { column: null, direction: "ascending" };
75
+
76
+ const { result } = renderHook(() =>
77
+ useSortedLinks(fallbackLinks, noSortColumns, sort),
78
+ );
79
+
80
+ expect(result.current.map((l) => l.name)).toEqual(["Alpha", "Beta"]);
81
+ });
82
+
83
+ it("sorts ascending by a specific column", () => {
84
+ const sort = { column: "name", direction: "ascending" };
85
+
86
+ const { result } = renderHook(() => useSortedLinks(links, columns, sort));
87
+
88
+ expect(result.current.map((l) => l.name)).toEqual([
89
+ "Alpha",
90
+ "Beta",
91
+ "Zulu",
92
+ ]);
93
+ });
94
+
95
+ it("sorts descending by a specific column", () => {
96
+ const sort = { column: "name", direction: "descending" };
97
+
98
+ const { result } = renderHook(() => useSortedLinks(links, columns, sort));
99
+
100
+ expect(result.current.map((l) => l.name)).toEqual([
101
+ "Zulu",
102
+ "Beta",
103
+ "Alpha",
104
+ ]);
105
+ });
106
+
107
+ it("recomputes when sort changes", () => {
108
+ const { result, rerender } = renderHook(
109
+ ({ sort }) => useSortedLinks(links, columns, sort),
110
+ {
111
+ initialProps: { sort: { column: "name", direction: "ascending" } },
112
+ },
113
+ );
114
+
115
+ expect(result.current.map((l) => l.name)).toEqual([
116
+ "Alpha",
117
+ "Beta",
118
+ "Zulu",
119
+ ]);
120
+
121
+ rerender({ sort: { column: "name", direction: "descending" } });
122
+
123
+ expect(result.current.map((l) => l.name)).toEqual([
124
+ "Zulu",
125
+ "Beta",
126
+ "Alpha",
127
+ ]);
128
+ });
129
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { renderHook, waitFor } from "@testing-library/react";
6
+ import { useDataStructureSearch } from "@truedat/dd/hooks/useStructures";
7
+ import { useStructureNotes } from "../useStructureNotes";
8
+
9
+ jest.mock(
10
+ "@truedat/dd/hooks/useStructures",
11
+ () => ({
12
+ useDataStructureSearch: jest.fn(),
13
+ }),
14
+ { virtual: true },
15
+ );
16
+
17
+ describe("useStructureNotes", () => {
18
+ beforeEach(() => {
19
+ const mockTrigger = jest.fn().mockResolvedValue({
20
+ data: {
21
+ data: [
22
+ { id: 123, note: { status: "published", content: "Test note" } },
23
+ { id: 456, note: { status: "draft", content: "Another note" } },
24
+ ],
25
+ },
26
+ });
27
+
28
+ useDataStructureSearch.mockReturnValue({
29
+ trigger: mockTrigger,
30
+ });
31
+ });
32
+
33
+ it("returns links unchanged if no data_structure resources", () => {
34
+ const links = [
35
+ { id: 1, resource_type: "other", resource_id: 123, name: "Other 1" },
36
+ ];
37
+
38
+ const { result } = renderHook(() => useStructureNotes(links));
39
+
40
+ expect(result.current).toEqual(links);
41
+ });
42
+
43
+ it("fetches and enriches data_structure links with notes", async () => {
44
+ const links = [
45
+ {
46
+ id: 1,
47
+ resource_type: "data_structure",
48
+ resource_id: 123,
49
+ name: "Structure 1",
50
+ },
51
+ {
52
+ id: 2,
53
+ resource_type: "data_structure",
54
+ resource_id: 456,
55
+ name: "Structure 2",
56
+ },
57
+ ];
58
+
59
+ const { result } = renderHook(() => useStructureNotes(links));
60
+
61
+ await waitFor(() => {
62
+ expect(result.current[0].note).toEqual({
63
+ status: "published",
64
+ content: "Test note",
65
+ });
66
+ });
67
+ });
68
+
69
+ it("calls trigger with correct structure IDs", async () => {
70
+ const mockTrigger = jest.fn().mockResolvedValue({
71
+ data: {
72
+ data: [],
73
+ },
74
+ });
75
+
76
+ useDataStructureSearch.mockReturnValue({
77
+ trigger: mockTrigger,
78
+ });
79
+
80
+ const links = [
81
+ {
82
+ id: 1,
83
+ resource_type: "data_structure",
84
+ resource_id: 123,
85
+ name: "Structure 1",
86
+ },
87
+ ];
88
+
89
+ renderHook(() => useStructureNotes(links));
90
+
91
+ await waitFor(() => {
92
+ expect(mockTrigger).toHaveBeenCalledWith({
93
+ must: { ids: [123] },
94
+ page: 0,
95
+ size: 1,
96
+ });
97
+ });
98
+ });
99
+
100
+ it("does not call trigger if no data_structure links", () => {
101
+ const mockTrigger = jest.fn();
102
+
103
+ useDataStructureSearch.mockReturnValue({
104
+ trigger: mockTrigger,
105
+ });
106
+
107
+ const links = [
108
+ { id: 1, resource_type: "other", resource_id: 123, name: "Other 1" },
109
+ ];
110
+
111
+ renderHook(() => useStructureNotes(links));
112
+
113
+ expect(mockTrigger).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("handles API errors gracefully", async () => {
117
+ const mockTrigger = jest.fn().mockRejectedValue(new Error("API Error"));
118
+
119
+ useDataStructureSearch.mockReturnValue({
120
+ trigger: mockTrigger,
121
+ });
122
+
123
+ const links = [
124
+ {
125
+ id: 1,
126
+ resource_type: "data_structure",
127
+ resource_id: 123,
128
+ name: "Structure 1",
129
+ },
130
+ ];
131
+
132
+ const { result } = renderHook(() => useStructureNotes(links));
133
+
134
+ await waitFor(() => {
135
+ expect(result.current).toEqual(links);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { renderHook, act } from "@testing-library/react";
6
+ import { useTruncatedPopup } from "../useTruncatedPopup";
7
+
8
+ describe("useTruncatedPopup", () => {
9
+ it("initializes with closed popup", () => {
10
+ const { result } = renderHook(() => useTruncatedPopup());
11
+
12
+ expect(result.current.popupOpen).toBe(false);
13
+ expect(result.current.popupContent).toBe("");
14
+ expect(result.current.cellRef.current).toBeNull();
15
+ });
16
+
17
+ it("opens popup when content overflows", () => {
18
+ const { result } = renderHook(() => useTruncatedPopup());
19
+
20
+ const mockCell = {
21
+ textContent: "Long text that overflows",
22
+ scrollWidth: 200,
23
+ clientWidth: 100,
24
+ };
25
+
26
+ act(() => {
27
+ result.current.handleMouseEnter({ currentTarget: mockCell });
28
+ });
29
+
30
+ expect(result.current.popupOpen).toBe(true);
31
+ expect(result.current.popupContent).toBe("Long text that overflows");
32
+ });
33
+
34
+ it("does not open popup when content fits", () => {
35
+ const { result } = renderHook(() => useTruncatedPopup());
36
+
37
+ const mockCell = {
38
+ textContent: "Short text",
39
+ scrollWidth: 100,
40
+ clientWidth: 100,
41
+ };
42
+
43
+ act(() => {
44
+ result.current.handleMouseEnter({ currentTarget: mockCell });
45
+ });
46
+
47
+ expect(result.current.popupOpen).toBe(false);
48
+ });
49
+
50
+ it("sets popupContent to empty string when textContent is empty", () => {
51
+ const { result } = renderHook(() => useTruncatedPopup());
52
+
53
+ const mockCell = {
54
+ textContent: " ",
55
+ scrollWidth: 200,
56
+ clientWidth: 100,
57
+ };
58
+
59
+ act(() => {
60
+ result.current.handleMouseEnter({ currentTarget: mockCell });
61
+ });
62
+
63
+ expect(result.current.popupOpen).toBe(false);
64
+ });
65
+
66
+ it("closes popup on mouse leave", () => {
67
+ const { result } = renderHook(() => useTruncatedPopup());
68
+
69
+ const mockCell = {
70
+ textContent: "Long text",
71
+ scrollWidth: 200,
72
+ clientWidth: 100,
73
+ };
74
+
75
+ act(() => {
76
+ result.current.handleMouseEnter({ currentTarget: mockCell });
77
+ });
78
+
79
+ expect(result.current.popupOpen).toBe(true);
80
+
81
+ act(() => {
82
+ result.current.handleMouseLeave();
83
+ });
84
+
85
+ expect(result.current.popupOpen).toBe(false);
86
+ });
87
+
88
+ it("manually closes popup via setPopupOpen", () => {
89
+ const { result } = renderHook(() => useTruncatedPopup());
90
+
91
+ const mockCell = {
92
+ textContent: "Long text",
93
+ scrollWidth: 200,
94
+ clientWidth: 100,
95
+ };
96
+
97
+ act(() => {
98
+ result.current.handleMouseEnter({ currentTarget: mockCell });
99
+ });
100
+
101
+ expect(result.current.popupOpen).toBe(true);
102
+
103
+ act(() => {
104
+ result.current.setPopupOpen(false);
105
+ });
106
+
107
+ expect(result.current.popupOpen).toBe(false);
108
+ });
109
+ });