@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
@@ -0,0 +1,98 @@
1
+ import _ from "lodash/fp";
2
+ import { useMemo } from "react";
3
+
4
+ const linksByRelationType = (links, selectedRelationTypes) => {
5
+ if (_.isEmpty(selectedRelationTypes)) return links;
6
+ return _.filter((link) => {
7
+ const linkTags = _.defaultTo([])(_.prop("tags")(link));
8
+ return _.some((relType) =>
9
+ relType === "" ? _.isEmpty(linkTags) : linkTags.includes(relType),
10
+ )(selectedRelationTypes);
11
+ })(links);
12
+ };
13
+
14
+ const linksByType = (links, selectedTypes) => {
15
+ if (_.isEmpty(selectedTypes)) return links;
16
+ return _.filter((link) => selectedTypes.includes(link.type))(links);
17
+ };
18
+
19
+ export const useFilteredLinks = ({
20
+ enrichedLinks,
21
+ selectedTypes,
22
+ selectedRelationTypes,
23
+ }) => {
24
+ const linksByRelationTypeFilter = useMemo(
25
+ () => linksByRelationType(enrichedLinks, selectedRelationTypes),
26
+ [enrichedLinks, selectedRelationTypes],
27
+ );
28
+
29
+ const typeOptions = useMemo(
30
+ () =>
31
+ _.flow(
32
+ _.map(_.prop("type")),
33
+ _.filter(Boolean),
34
+ _.uniq,
35
+ _.sortBy(_.identity),
36
+ )(linksByRelationTypeFilter),
37
+ [linksByRelationTypeFilter],
38
+ );
39
+
40
+ const linksByTypeFilter = useMemo(
41
+ () => linksByType(enrichedLinks, selectedTypes),
42
+ [enrichedLinks, selectedTypes],
43
+ );
44
+
45
+ const relationTypeOptions = useMemo(() => {
46
+ const allTags = _.flow(
47
+ _.map(_.prop("tags")),
48
+ _.filter(Array.isArray),
49
+ _.flatten,
50
+ )(linksByTypeFilter);
51
+ const hasEmptyTags = _.some(
52
+ (link) =>
53
+ _.isNil(_.prop("tags")(link)) || _.isEmpty(_.prop("tags")(link)),
54
+ )(linksByTypeFilter);
55
+ const tagOptions = _.flow(
56
+ _.filter(Boolean),
57
+ _.uniq,
58
+ _.sortBy(_.identity),
59
+ )(allTags);
60
+ return hasEmptyTags ? ["", ...tagOptions] : tagOptions;
61
+ }, [linksByTypeFilter]);
62
+
63
+ const filteredLinks = useMemo(
64
+ () =>
65
+ _.filter((link) => {
66
+ const activeSelectedTypes = _.filter((type) =>
67
+ typeOptions.includes(type),
68
+ )(selectedTypes);
69
+ const activeSelectedRelationTypes = _.filter((relType) =>
70
+ relationTypeOptions.includes(relType),
71
+ )(selectedRelationTypes);
72
+
73
+ const typeMatch =
74
+ _.isEmpty(activeSelectedTypes) ||
75
+ activeSelectedTypes.includes(link.type);
76
+
77
+ const relationTypeMatch =
78
+ _.isEmpty(activeSelectedRelationTypes) ||
79
+ _.some((relType) => {
80
+ const linkTags = _.defaultTo([])(_.prop("tags")(link));
81
+ return relType === ""
82
+ ? _.isEmpty(linkTags)
83
+ : linkTags.includes(relType);
84
+ })(activeSelectedRelationTypes);
85
+
86
+ return typeMatch && relationTypeMatch;
87
+ })(enrichedLinks),
88
+ [
89
+ enrichedLinks,
90
+ selectedTypes,
91
+ typeOptions,
92
+ selectedRelationTypes,
93
+ relationTypeOptions,
94
+ ],
95
+ );
96
+
97
+ return { filteredLinks, typeOptions, relationTypeOptions };
98
+ };
@@ -0,0 +1,8 @@
1
+ import { useMemo } from "react";
2
+ import { sortLinks } from "../utils/linksPaneHelpers";
3
+
4
+ export const useSortedLinks = (filteredLinks, filteredColumns, sort) =>
5
+ useMemo(
6
+ () => sortLinks(filteredLinks, filteredColumns, sort),
7
+ [filteredColumns, filteredLinks, sort],
8
+ );
@@ -0,0 +1,52 @@
1
+ import _ from "lodash/fp";
2
+ import { useEffect, useState } from "react";
3
+ import { useDataStructureSearch } from "@truedat/dd/hooks/useStructures";
4
+
5
+ export const useStructureNotes = (links) => {
6
+ const [enrichedLinks, setEnrichedLinks] = useState(links);
7
+ const { trigger } = useDataStructureSearch();
8
+
9
+ useEffect(() => {
10
+ const fetchStructureNotes = async () => {
11
+ const structureIds = _.flow(
12
+ _.filter(_.propEq("resource_type", "data_structure")),
13
+ _.map(({ resource_id }) => parseInt(resource_id)),
14
+ _.uniq,
15
+ )(links);
16
+
17
+ if (_.isEmpty(structureIds)) {
18
+ setEnrichedLinks(links);
19
+ return;
20
+ }
21
+
22
+ try {
23
+ const response = await trigger({
24
+ must: { ids: structureIds },
25
+ page: 0,
26
+ size: structureIds.length,
27
+ });
28
+
29
+ const structuresMap = _.flow(
30
+ _.getOr([], "data.data"),
31
+ _.keyBy("id"),
32
+ )(response);
33
+
34
+ const enriched = _.map((link) => {
35
+ if (link.resource_type === "data_structure") {
36
+ const structure = structuresMap[link.resource_id];
37
+ return { ...link, note: structure?.note || null };
38
+ }
39
+ return link;
40
+ })(links);
41
+
42
+ setEnrichedLinks(enriched);
43
+ } catch (error) {
44
+ setEnrichedLinks(links);
45
+ }
46
+ };
47
+
48
+ fetchStructureNotes();
49
+ }, [links, trigger]);
50
+
51
+ return enrichedLinks;
52
+ };
@@ -0,0 +1,30 @@
1
+ import { useRef, useState } from "react";
2
+
3
+ export const useTruncatedPopup = () => {
4
+ const cellRef = useRef(null);
5
+ const [popupOpen, setPopupOpen] = useState(false);
6
+ const [popupContent, setPopupContent] = useState("");
7
+
8
+ const handleMouseEnter = (event) => {
9
+ const current = event.currentTarget;
10
+ const renderedContent = current?.textContent?.trim() || "";
11
+
12
+ setPopupContent(renderedContent);
13
+ setPopupOpen(
14
+ Boolean(
15
+ renderedContent && current && current.scrollWidth > current.clientWidth,
16
+ ),
17
+ );
18
+ };
19
+
20
+ const handleMouseLeave = () => setPopupOpen(false);
21
+
22
+ return {
23
+ cellRef,
24
+ popupOpen,
25
+ popupContent,
26
+ handleMouseEnter,
27
+ handleMouseLeave,
28
+ setPopupOpen,
29
+ };
30
+ };
@@ -0,0 +1,117 @@
1
+ .links-pane__popup,
2
+ .links-pane__truncated-popup {
3
+ max-width: 420px;
4
+ overflow-wrap: anywhere;
5
+ white-space: pre-wrap;
6
+ word-break: break-word;
7
+ }
8
+
9
+ .links-pane__table-scroll {
10
+ margin: 0 -8px;
11
+ max-width: calc(100% + 16px);
12
+ overflow-x: auto;
13
+ padding: 0 8px;
14
+ }
15
+
16
+ .ui.table.links-pane__table {
17
+ min-width: var(--links-pane-table-min-width);
18
+ table-layout: fixed;
19
+ width: 100%;
20
+ }
21
+
22
+ .links-pane__column,
23
+ .ui.table .links-pane__cell {
24
+ min-width: var(--links-pane-column-width);
25
+ width: var(--links-pane-column-width);
26
+ }
27
+
28
+ .ui.table .links-pane__cell {
29
+ max-width: 0;
30
+ overflow: hidden;
31
+ text-overflow: ellipsis;
32
+ vertical-align: middle;
33
+ white-space: nowrap;
34
+ }
35
+
36
+ .ui.table.links-pane__table th.links-pane__cell--header {
37
+ padding-right: 32px;
38
+ position: relative;
39
+ vertical-align: middle;
40
+ }
41
+
42
+ .ui.table.links-pane__table th.links-pane__cell--header .links-pane__filter-label {
43
+ display: inline-block;
44
+ padding-right: 0;
45
+ vertical-align: middle;
46
+ white-space: normal;
47
+ width: calc(100% - 32px);
48
+ }
49
+
50
+ .ui.sortable.table thead th.links-pane__cell--header::after {
51
+ position: absolute !important;
52
+ right: 8px;
53
+ top: 50%;
54
+ transform: translateY(-50%);
55
+ }
56
+
57
+ .ui.table .links-pane__cell--wrap {
58
+ overflow-wrap: anywhere;
59
+ text-overflow: clip;
60
+ white-space: normal;
61
+ }
62
+
63
+ .ui.table .links-pane__cell--clip {
64
+ text-overflow: clip;
65
+ }
66
+
67
+ .links-pane__note-trigger {
68
+ align-items: center;
69
+ display: inline-flex;
70
+ justify-content: center;
71
+ width: 100%;
72
+ }
73
+
74
+ .links-pane__note-icon {
75
+ cursor: default;
76
+ margin: 0;
77
+ max-width: 100%;
78
+ }
79
+
80
+ .links-pane__filter-label {
81
+ display: inline-block;
82
+ max-width: 100%;
83
+ vertical-align: middle;
84
+ }
85
+
86
+ .links-pane__filter-label-text {
87
+ display: block;
88
+ white-space: normal;
89
+ }
90
+
91
+ .links-pane__filter-icon {
92
+ cursor: pointer;
93
+ margin: 0;
94
+ position: absolute;
95
+ right: 24px;
96
+ top: 50%;
97
+ transform: translateY(-50%);
98
+ }
99
+
100
+ .links-pane__filter-menu {
101
+ background: #fff;
102
+ border-radius: 6px;
103
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
104
+ max-height: 260px;
105
+ min-width: 180px;
106
+ overflow-y: auto;
107
+ padding: 8px 0;
108
+ z-index: 2147483647;
109
+ }
110
+
111
+ .links-pane__filter-menu-item {
112
+ align-items: center;
113
+ cursor: pointer;
114
+ display: flex;
115
+ padding: 8px 14px;
116
+ white-space: nowrap;
117
+ }
@@ -0,0 +1,7 @@
1
+ .ui.mini.compact.icon.button.relation-actions__delete-button {
2
+ padding: 4px;
3
+ }
4
+
5
+ .ui.icon.button > .icon.relation-actions__delete-icon {
6
+ margin: 0;
7
+ }
@@ -0,0 +1,316 @@
1
+ import _ from "lodash/fp";
2
+ import {
3
+ getColumnValue,
4
+ normalizeValue,
5
+ normalizeSortValue,
6
+ uniqueSorted,
7
+ getDefaultSortColumns,
8
+ sortLinks,
9
+ isSortableColumn,
10
+ normalizeColumn,
11
+ columnUnits,
12
+ columnWidth,
13
+ columnsWidth,
14
+ mergeClassNames,
15
+ columnClassName,
16
+ columnWidthStyle,
17
+ tableWidthStyle,
18
+ NOTE_COLUMN,
19
+ ACTION_COLUMN,
20
+ GROUP_COLUMN,
21
+ WRAP_COLUMNS,
22
+ CLIP_COLUMNS,
23
+ DEFAULT_COLUMN_WIDTHS,
24
+ COLUMN_UNIT_WIDTH,
25
+ } from "../linksPaneHelpers";
26
+
27
+ jest.mock(
28
+ "@truedat/core/services",
29
+ () => ({
30
+ columnDecorator: () => () => null,
31
+ columnPredicate: () => () => true,
32
+ }),
33
+ { virtual: true },
34
+ );
35
+
36
+ describe("linksPaneHelpers", () => {
37
+ describe("getColumnValue", () => {
38
+ const link = { name: "Alpha", tags: ["main"], system: { name: "dbt" } };
39
+
40
+ it("returns value by path when no fieldSelector", () => {
41
+ expect(getColumnValue({ name: "name" }, link)).toBe("Alpha");
42
+ });
43
+
44
+ it("returns value from fieldSelector", () => {
45
+ const column = {
46
+ name: "type",
47
+ fieldSelector: _.path("tags"),
48
+ };
49
+ expect(getColumnValue(column, link)).toEqual(["main"]);
50
+ });
51
+
52
+ it("returns undefined for missing property", () => {
53
+ expect(getColumnValue({ name: "nonexistent" }, link)).toBeUndefined();
54
+ });
55
+
56
+ it("handles null column", () => {
57
+ expect(getColumnValue(null, link)).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe("normalizeValue", () => {
62
+ it("returns empty string for nil", () => {
63
+ expect(normalizeValue(null)).toBe("");
64
+ expect(normalizeValue(undefined)).toBe("");
65
+ });
66
+
67
+ it("joins array values", () => {
68
+ expect(normalizeValue(["main", "secondary"])).toBe("main secondary");
69
+ });
70
+
71
+ it("extracts value.type from object", () => {
72
+ expect(normalizeValue({ value: { type: "column" } })).toBe("column");
73
+ });
74
+
75
+ it("extracts type from object", () => {
76
+ expect(normalizeValue({ type: "field" })).toBe("field");
77
+ });
78
+
79
+ it("extracts name from object", () => {
80
+ expect(normalizeValue({ name: "Test" })).toBe("Test");
81
+ });
82
+
83
+ it("extracts full_name from object", () => {
84
+ expect(normalizeValue({ full_name: "Full Name" })).toBe("Full Name");
85
+ });
86
+
87
+ it("returns empty string for empty array", () => {
88
+ expect(normalizeValue([])).toBe("");
89
+ });
90
+
91
+ it("stringifies other objects", () => {
92
+ const obj = { foo: "bar" };
93
+ expect(normalizeValue(obj)).toBe(JSON.stringify(obj));
94
+ });
95
+
96
+ it("converts numbers to string", () => {
97
+ expect(normalizeValue(42)).toBe("42");
98
+ });
99
+ });
100
+
101
+ describe("normalizeSortValue", () => {
102
+ it("lowercases the normalized value", () => {
103
+ expect(normalizeSortValue("Alpha")).toBe("alpha");
104
+ });
105
+
106
+ it("handles arrays", () => {
107
+ expect(normalizeSortValue(["Main", "Secondary"])).toBe("main secondary");
108
+ });
109
+ });
110
+
111
+ describe("uniqueSorted", () => {
112
+ it("returns unique sorted values", () => {
113
+ expect(uniqueSorted(["b", "a", "b", "c"])).toEqual(["a", "b", "c"]);
114
+ });
115
+
116
+ it("filters out falsy values", () => {
117
+ expect(uniqueSorted(["a", null, "b", undefined, ""])).toEqual(["a", "b"]);
118
+ });
119
+
120
+ it("returns empty array for empty input", () => {
121
+ expect(uniqueSorted([])).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe("isSortableColumn", () => {
126
+ it("returns true for non-actions columns", () => {
127
+ expect(isSortableColumn({ name: "name" })).toBe(true);
128
+ });
129
+
130
+ it("returns false for _actions column", () => {
131
+ expect(isSortableColumn({ name: "_actions" })).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe("getDefaultSortColumns", () => {
136
+ const columns = [
137
+ { name: "name" },
138
+ { name: "relation.relation_type_name" },
139
+ { name: "type" },
140
+ ];
141
+
142
+ it("finds default sort columns from columns list", () => {
143
+ const result = getDefaultSortColumns(columns);
144
+ expect(result.map((c) => c.name)).toEqual([
145
+ "relation.relation_type_name",
146
+ "type",
147
+ ]);
148
+ });
149
+
150
+ it("returns empty array when no default sort columns found", () => {
151
+ const result = getDefaultSortColumns([{ name: "_actions" }]);
152
+ expect(result).toEqual([]);
153
+ });
154
+ });
155
+
156
+ describe("sortLinks", () => {
157
+ const columns = [
158
+ { name: "name" },
159
+ {
160
+ name: "relation.relation_type_name",
161
+ fieldSelector: (link) => link.tags,
162
+ },
163
+ { name: "type" },
164
+ ];
165
+ const links = [
166
+ { id: 1, name: "Zulu", tags: ["secondary"], type: "field" },
167
+ { id: 2, name: "Alpha", tags: ["main"], type: "table" },
168
+ { id: 3, name: "Beta", tags: ["main"], type: "column" },
169
+ ];
170
+
171
+ it("sorts by relation type then type by default", () => {
172
+ const result = sortLinks(links, columns, {
173
+ column: null,
174
+ direction: "ascending",
175
+ });
176
+ expect(result.map((l) => l.name)).toEqual(["Beta", "Alpha", "Zulu"]);
177
+ });
178
+
179
+ it("sorts by type when relation type column not present", () => {
180
+ const typeOnlyColumns = [{ name: "name" }, { name: "type" }];
181
+ const result = sortLinks(links, typeOnlyColumns, {
182
+ column: null,
183
+ direction: "ascending",
184
+ });
185
+ expect(result.map((l) => l.name)).toEqual(["Beta", "Zulu", "Alpha"]);
186
+ });
187
+
188
+ it("falls back to name asc when no default sort columns", () => {
189
+ const noSortColumns = [{ name: "_actions" }];
190
+ const fallbackLinks = [
191
+ { id: 1, name: "Zulu" },
192
+ { id: 2, name: "Alpha" },
193
+ ];
194
+ const result = sortLinks(fallbackLinks, noSortColumns, {
195
+ column: null,
196
+ direction: "ascending",
197
+ });
198
+ expect(result.map((l) => l.name)).toEqual(["Alpha", "Zulu"]);
199
+ });
200
+
201
+ it("sorts by a specific column ascending", () => {
202
+ const result = sortLinks(links, columns, {
203
+ column: "name",
204
+ direction: "ascending",
205
+ });
206
+ expect(result.map((l) => l.name)).toEqual(["Alpha", "Beta", "Zulu"]);
207
+ });
208
+
209
+ it("sorts by a specific column descending", () => {
210
+ const result = sortLinks(links, columns, {
211
+ column: "name",
212
+ direction: "descending",
213
+ });
214
+ expect(result.map((l) => l.name)).toEqual(["Zulu", "Beta", "Alpha"]);
215
+ });
216
+ });
217
+
218
+ describe("normalizeColumn", () => {
219
+ it("adds default textAlign for note columns", () => {
220
+ const col = normalizeColumn({ name: NOTE_COLUMN });
221
+ expect(col.textAlign).toBe("center");
222
+ });
223
+
224
+ it("adds default textAlign for group columns", () => {
225
+ const col = normalizeColumn({ name: GROUP_COLUMN });
226
+ expect(col.textAlign).toBe("center");
227
+ });
228
+
229
+ it("preserves existing textAlign", () => {
230
+ const col = normalizeColumn({ name: "name", textAlign: "right" });
231
+ expect(col.textAlign).toBe("right");
232
+ });
233
+
234
+ it("applies default width from DEFAULT_COLUMN_WIDTHS", () => {
235
+ const col = normalizeColumn({ name: "name" });
236
+ expect(col.width).toBe(DEFAULT_COLUMN_WIDTHS.name);
237
+ });
238
+
239
+ it("preserves existing width", () => {
240
+ const col = normalizeColumn({ name: "name", width: 5 });
241
+ expect(col.width).toBe(5);
242
+ });
243
+
244
+ it("defaults width to 1 for unknown columns", () => {
245
+ const col = normalizeColumn({ name: "custom" });
246
+ expect(col.width).toBe(undefined);
247
+ });
248
+ });
249
+
250
+ describe("columnWidth calculations", () => {
251
+ it("columnUnits defaults to 1", () => {
252
+ expect(columnUnits({})).toBe(1);
253
+ });
254
+
255
+ it("columnUnits returns custom width", () => {
256
+ expect(columnUnits({ width: 2.2 })).toBe(2.2);
257
+ });
258
+
259
+ it("columnWidth multiplies units by COLUMN_UNIT_WIDTH", () => {
260
+ expect(columnWidth({ width: 2 })).toBe("240px");
261
+ });
262
+
263
+ it("columnsWidth sums all column units", () => {
264
+ const columns = [{ width: 2 }, {}, { width: 1 }];
265
+ expect(columnsWidth(columns)).toBe("480px");
266
+ });
267
+ });
268
+
269
+ describe("mergeClassNames", () => {
270
+ it("joins truthy class names", () => {
271
+ expect(mergeClassNames("a", "b", false, undefined, "c")).toBe("a b c");
272
+ });
273
+
274
+ it("returns empty string for all falsy", () => {
275
+ expect(mergeClassNames(false, undefined, null)).toBe("");
276
+ });
277
+ });
278
+
279
+ describe("columnClassName", () => {
280
+ it("includes base cell class", () => {
281
+ const result = columnClassName({ name: "name" });
282
+ expect(result).toContain("links-pane__cell");
283
+ });
284
+
285
+ it("includes header class when header is true", () => {
286
+ const result = columnClassName({ name: "name" }, true);
287
+ expect(result).toContain("links-pane__cell--header");
288
+ });
289
+
290
+ it("includes wrap class for name columns", () => {
291
+ const result = columnClassName({ name: "name" });
292
+ expect(result).toContain("links-pane__cell--wrap");
293
+ });
294
+
295
+ it("includes clip class for _actions columns", () => {
296
+ const result = columnClassName({ name: "_actions" });
297
+ expect(result).toContain("links-pane__cell--clip");
298
+ });
299
+ });
300
+
301
+ describe("width style creators", () => {
302
+ it("columnWidthStyle returns CSS custom property", () => {
303
+ const col = { width: 2 };
304
+ expect(columnWidthStyle(col)).toEqual({
305
+ "--links-pane-column-width": "240px",
306
+ });
307
+ });
308
+
309
+ it("tableWidthStyle returns CSS custom property", () => {
310
+ const cols = [{ width: 2 }, { width: 1 }];
311
+ expect(tableWidthStyle(cols)).toEqual({
312
+ "--links-pane-table-min-width": "360px",
313
+ });
314
+ });
315
+ });
316
+ });