@truedat/core 8.11.6 → 8.11.9

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,15 @@
1
- import { fireEvent, screen, waitFor } from "@testing-library/react";
1
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
2
2
  import { render } from "@truedat/test/render";
3
3
  import { Graph, getMinZoomForDiagram } from "../Graph";
4
4
 
5
5
  const mockGetNodesBounds = jest.fn();
6
+ const mockGetFlowNodesBounds = jest.fn();
6
7
  const mockGetViewportForBounds = jest.fn();
8
+ const mockGetNodes = jest.fn().mockReturnValue([]);
9
+ const mockGetViewport = jest.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 });
10
+ const mockFitView = jest.fn();
11
+ const mockSetCenter = jest.fn();
12
+ const mockSetViewport = jest.fn();
7
13
 
8
14
  jest.mock("elkjs/lib/elk.bundled.js", () =>
9
15
  jest.fn().mockImplementation(() => ({
@@ -12,15 +18,25 @@ jest.mock("elkjs/lib/elk.bundled.js", () =>
12
18
  );
13
19
 
14
20
  jest.mock("@xyflow/react", () => ({
15
- ReactFlow: ({ children }) => <div data-testid="react-flow">{children}</div>,
21
+ ReactFlow: ({ children, onInit }) => {
22
+ const React = require("react");
23
+
24
+ React.useEffect(() => onInit?.(), [onInit]);
25
+
26
+ return <div data-testid="react-flow">{children}</div>;
27
+ },
16
28
  ReactFlowProvider: ({ children }) => <>{children}</>,
17
29
  useNodesState: (initialNodes) => [initialNodes, jest.fn(), jest.fn()],
18
30
  useEdgesState: (initialEdges) => [initialEdges, jest.fn(), jest.fn()],
19
31
  useReactFlow: () => ({
20
- fitView: jest.fn(),
32
+ fitView: mockFitView,
21
33
  zoomIn: jest.fn(),
22
34
  zoomOut: jest.fn(),
23
- getNodes: jest.fn().mockReturnValue([]),
35
+ getNodes: mockGetNodes,
36
+ getNodesBounds: mockGetFlowNodesBounds,
37
+ getViewport: mockGetViewport,
38
+ setCenter: mockSetCenter,
39
+ setViewport: mockSetViewport,
24
40
  }),
25
41
  Position: {
26
42
  Right: "right",
@@ -31,7 +47,15 @@ jest.mock("@xyflow/react", () => ({
31
47
  MarkerType: {
32
48
  ArrowClosed: "arrowclosed",
33
49
  },
34
- Controls: ({ children }) => <div>{children}</div>,
50
+ Controls: ({ children, className, orientation }) => (
51
+ <div
52
+ className={className}
53
+ data-orientation={orientation}
54
+ data-testid="graph-controls"
55
+ >
56
+ {children}
57
+ </div>
58
+ ),
35
59
  ControlButton: ({ children, ...props }) => (
36
60
  <button type="button" {...props}>
37
61
  {children}
@@ -56,40 +80,189 @@ describe("<Graph />", () => {
56
80
  };
57
81
 
58
82
  beforeEach(() => {
83
+ jest.useFakeTimers();
59
84
  mockGetNodesBounds.mockReturnValue({ x: 0, y: 0, width: 100, height: 50 });
85
+ mockGetFlowNodesBounds.mockReturnValue({
86
+ x: 0,
87
+ y: 0,
88
+ width: 100,
89
+ height: 50,
90
+ });
60
91
  mockGetViewportForBounds.mockReturnValue({ x: 0, y: 0, zoom: 0.75 });
92
+ mockGetNodes.mockReturnValue([]);
93
+ mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 });
94
+ mockFitView.mockClear();
95
+ mockSetCenter.mockClear();
96
+ mockSetViewport.mockClear();
61
97
  });
62
98
 
63
99
  afterEach(() => {
100
+ jest.useRealTimers();
64
101
  mockGetNodesBounds.mockReset();
102
+ mockGetFlowNodesBounds.mockReset();
65
103
  mockGetViewportForBounds.mockReset();
104
+ mockFitView.mockReset();
105
+ mockSetCenter.mockReset();
106
+ mockSetViewport.mockReset();
66
107
  });
67
108
 
68
- it("uses the fit view zoom as the minimum zoom level", () => {
69
- expect(getMinZoomForDiagram(props.nodes, 600, 300)).toBe(0.75);
109
+ it("keeps the configured floor when the full diagram fits above it", () => {
110
+ expect(getMinZoomForDiagram(props.nodes, 600, 300)).toBe(0.03);
70
111
  expect(mockGetViewportForBounds).toHaveBeenCalledWith(
71
112
  { x: 0, y: 0, width: 100, height: 50 },
72
113
  600,
73
114
  300,
74
- 0.01,
75
- 0.8,
76
- 0.2,
115
+ 0.03,
116
+ 2,
117
+ 0.15,
77
118
  );
78
119
  });
79
120
 
121
+ it("accepts instance-aware bounds for graphs with subflows", () => {
122
+ expect(
123
+ getMinZoomForDiagram(
124
+ props.nodes,
125
+ 600,
126
+ 300,
127
+ 0.15,
128
+ 0.03,
129
+ 2,
130
+ mockGetFlowNodesBounds,
131
+ ),
132
+ ).toBe(0.03);
133
+ expect(mockGetFlowNodesBounds).toHaveBeenCalledWith(props.nodes);
134
+ });
135
+
80
136
  it("opens an expanded modal from the controls bar", async () => {
81
137
  render(<Graph {...props} />);
82
138
 
83
- expect(screen.queryByText("graph.expanded.header")).not.toBeInTheDocument();
139
+ expect(screen.getAllByTestId("react-flow")).toHaveLength(1);
84
140
 
85
141
  fireEvent.click(screen.getByLabelText("graph.controls.open_expanded"));
86
142
 
87
143
  await waitFor(() => {
88
- expect(screen.getByText("graph.expanded.header")).toBeInTheDocument();
89
144
  expect(screen.getAllByTestId("react-flow")).toHaveLength(2);
145
+ screen
146
+ .getAllByTestId("graph-controls")
147
+ .forEach((controls) =>
148
+ expect(controls).toHaveAttribute("data-orientation", "horizontal"),
149
+ );
90
150
  expect(
91
151
  screen.getAllByLabelText("graph.controls.open_expanded"),
92
152
  ).toHaveLength(1);
93
153
  });
94
154
  });
155
+
156
+ it("uses horizontal controls by default and keeps vertical controls in fullscreen", async () => {
157
+ render(
158
+ <Graph {...props} graphOptions={{ controlsOrientation: "vertical" }} />,
159
+ );
160
+
161
+ expect(screen.getByTestId("graph-controls")).toHaveAttribute(
162
+ "data-orientation",
163
+ "vertical",
164
+ );
165
+
166
+ fireEvent.click(screen.getByLabelText("graph.controls.open_expanded"));
167
+
168
+ await waitFor(() => {
169
+ expect(screen.getAllByTestId("graph-controls")).toHaveLength(2);
170
+ screen
171
+ .getAllByTestId("graph-controls")
172
+ .forEach((controls) =>
173
+ expect(controls).toHaveAttribute("data-orientation", "vertical"),
174
+ );
175
+ });
176
+ });
177
+
178
+ it("centers the same node again when its focus request key changes", async () => {
179
+ const focusNode = {
180
+ ...props.nodes[0],
181
+ width: 120,
182
+ height: 60,
183
+ position: { x: 80, y: 40 },
184
+ };
185
+
186
+ mockGetNodes.mockReturnValue([focusNode]);
187
+ const { rerender } = render(
188
+ <Graph
189
+ {...props}
190
+ graphOptions={{
191
+ focusNodeId: focusNode.id,
192
+ focusNodeRequestKey: 1,
193
+ layoutMode: "fixed",
194
+ }}
195
+ />,
196
+ );
197
+
198
+ await act(async () => {
199
+ jest.advanceTimersByTime(50);
200
+ });
201
+ const initialFocusCalls = mockSetCenter.mock.calls.length;
202
+
203
+ expect(initialFocusCalls).toBeGreaterThan(0);
204
+
205
+ rerender(
206
+ <Graph
207
+ {...props}
208
+ graphOptions={{
209
+ focusNodeId: focusNode.id,
210
+ focusNodeRequestKey: 2,
211
+ layoutMode: "fixed",
212
+ }}
213
+ />,
214
+ );
215
+ await act(async () => {
216
+ jest.advanceTimersByTime(50);
217
+ });
218
+
219
+ expect(mockSetCenter.mock.calls.length).toBeGreaterThan(initialFocusCalls);
220
+ expect(mockSetCenter).toHaveBeenLastCalledWith(140, 70, {
221
+ duration: 300,
222
+ zoom: expect.any(Number),
223
+ });
224
+ });
225
+
226
+ it("preserves x, y and zoom when a fixed layout changes internally", async () => {
227
+ const viewport = { x: -240, y: 96, zoom: 0.72 };
228
+
229
+ mockGetViewport.mockReturnValue(viewport);
230
+ const { rerender } = render(
231
+ <Graph
232
+ {...props}
233
+ allowExpandedView={false}
234
+ graphOptions={{
235
+ layoutMode: "fixed",
236
+ preserveUserViewportOnLayoutChange: true,
237
+ }}
238
+ />,
239
+ );
240
+
241
+ await act(async () => {
242
+ jest.advanceTimersByTime(50);
243
+ });
244
+ mockFitView.mockClear();
245
+ mockSetCenter.mockClear();
246
+ mockSetViewport.mockClear();
247
+
248
+ rerender(
249
+ <Graph
250
+ {...props}
251
+ allowExpandedView={false}
252
+ nodes={props.nodes.map((node) => ({ ...node, height: 240 }))}
253
+ graphOptions={{
254
+ layoutMode: "fixed",
255
+ preserveUserViewportOnLayoutChange: true,
256
+ }}
257
+ />,
258
+ );
259
+ await act(async () => {
260
+ jest.advanceTimersByTime(50);
261
+ });
262
+
263
+ expect(mockGetViewport()).toEqual(viewport);
264
+ expect(mockFitView).not.toHaveBeenCalled();
265
+ expect(mockSetCenter).not.toHaveBeenCalled();
266
+ expect(mockSetViewport).not.toHaveBeenCalled();
267
+ });
95
268
  });
@@ -0,0 +1,45 @@
1
+ import { fireEvent } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import ColoredEdge from "@truedat/core/components/graph/ColoredEdge";
4
+ import { redesignedLineageEdges } from "@truedat/dd/components/LineageGraph";
5
+
6
+ jest.mock("@xyflow/react", () => ({
7
+ BaseEdge: ({ className, id, path, style }) => (
8
+ <path className={className} d={path} id={id} style={style} />
9
+ ),
10
+ EdgeLabelRenderer: ({ children }) => children,
11
+ getBezierPath: () => ["M 0,0 L 400,0", 200, 0],
12
+ }));
13
+
14
+ it("lets the rendered edge respond to hover after lineage style normalization", () => {
15
+ const [edge] = redesignedLineageEdges([
16
+ {
17
+ id: "edge",
18
+ source: "a",
19
+ target: "b",
20
+ style: { stroke: "#b0b8c8", strokeWidth: 2, strokeOpacity: 0.95 },
21
+ data: {},
22
+ },
23
+ ]);
24
+ const { container } = render(
25
+ <svg>
26
+ <ColoredEdge
27
+ {...edge}
28
+ sourceX={0}
29
+ sourceY={0}
30
+ targetX={400}
31
+ targetY={0}
32
+ sourcePosition="right"
33
+ targetPosition="left"
34
+ />
35
+ </svg>,
36
+ );
37
+ const path = container.querySelector(".td-graph-edge");
38
+ expect(path.style.strokeWidth).toBe("");
39
+ expect(path.style.strokeOpacity).toBe("");
40
+ expect(path.style.stroke).toContain("var(--td-graph-edge-stroke");
41
+ fireEvent.mouseEnter(container.querySelector(".td-graph-edge-hit-area"));
42
+ expect(path).toHaveClass("td-graph-edge--hovered");
43
+ fireEvent.mouseLeave(container.querySelector(".td-graph-edge-hit-area"));
44
+ expect(path).not.toHaveClass("td-graph-edge--hovered");
45
+ });
@@ -0,0 +1,308 @@
1
+ import { fireEvent } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import LineageGroupNode from "../graph/LineageGroupNode";
4
+ import { EdgeHoverContext } from "../graph/ColoredEdge";
5
+ import { lineageNodeIcon } from "../graph/lineageNodeIcon";
6
+
7
+ jest.mock("@xyflow/react", () => ({
8
+ // eslint-disable-next-line react/prop-types
9
+ Handle: ({ id }) => <span data-handle-id={id} />,
10
+ Position: {
11
+ Bottom: "bottom",
12
+ Left: "left",
13
+ Right: "right",
14
+ Top: "top",
15
+ },
16
+ }));
17
+
18
+ describe("<LineageGroupNode />", () => {
19
+ it("renders the redesigned system hierarchy header", () => {
20
+ const rendered = render(
21
+ <LineageGroupNode
22
+ id="system-1"
23
+ data={{
24
+ designVariant: "redesigned",
25
+ label: "Teradata",
26
+ systemTypeLabel: "Warehouse",
27
+ variant: "absolute",
28
+ z: 1,
29
+ }}
30
+ />,
31
+ );
32
+
33
+ expect(rendered.getByText("Teradata")).toBeInTheDocument();
34
+ expect(rendered.getByText("Warehouse")).toBeInTheDocument();
35
+ expect(
36
+ rendered.container.querySelector(
37
+ ".td-lineage-group-node--design-redesigned",
38
+ ),
39
+ ).toBeInTheDocument();
40
+ expect(
41
+ rendered.container.querySelector(
42
+ ".td-lineage-group-node__system-indicator",
43
+ ),
44
+ ).not.toBeInTheDocument();
45
+ expect(
46
+ rendered.container.querySelectorAll("[data-handle-id]"),
47
+ ).toHaveLength(4);
48
+ expect(
49
+ rendered.container.querySelector(
50
+ ".td-lineage-group-node__type-icon.server.icon",
51
+ ),
52
+ ).toBeInTheDocument();
53
+ });
54
+
55
+ it("keeps collapse controls semantic and keyboard reachable", () => {
56
+ const onToggleCollapse = jest.fn();
57
+ const rendered = render(
58
+ <LineageGroupNode
59
+ id="database-1"
60
+ data={{
61
+ canCollapse: true,
62
+ collapsed: false,
63
+ designVariant: "redesigned",
64
+ label: "Analytics",
65
+ onToggleCollapse,
66
+ variant: "absolute",
67
+ z: 2,
68
+ }}
69
+ />,
70
+ );
71
+
72
+ expect(
73
+ rendered.getByRole("button", { name: "Collapse group" }),
74
+ ).toBeInTheDocument();
75
+ expect(
76
+ rendered.container.querySelector(
77
+ ".td-lineage-group-node__type-icon.database.icon",
78
+ ),
79
+ ).toBeInTheDocument();
80
+ });
81
+
82
+ it("omits a redundant single-node summary when the leaf group is open", () => {
83
+ const rendered = render(
84
+ <LineageGroupNode
85
+ id="open-leaf-group"
86
+ data={{
87
+ collapsed: false,
88
+ designVariant: "redesigned",
89
+ groupCount: 0,
90
+ label: "Leaf group",
91
+ nodeCount: 1,
92
+ variant: "absolute",
93
+ }}
94
+ />,
95
+ );
96
+
97
+ expect(rendered.queryByText("1 nodo")).not.toBeInTheDocument();
98
+ expect(
99
+ rendered.container.querySelector(".td-lineage-group-node__footer"),
100
+ ).not.toBeInTheDocument();
101
+ });
102
+
103
+ it("keeps the single-node summary while the leaf group is collapsed", () => {
104
+ const rendered = render(
105
+ <LineageGroupNode
106
+ id="collapsed-leaf-group"
107
+ data={{
108
+ collapsed: true,
109
+ designVariant: "redesigned",
110
+ groupCount: 0,
111
+ label: "Leaf group",
112
+ nodeCount: 1,
113
+ variant: "absolute",
114
+ }}
115
+ />,
116
+ );
117
+
118
+ expect(rendered.getByText("1 nodo")).toBeInTheDocument();
119
+ });
120
+
121
+ it("retains an explicit classic modifier", () => {
122
+ const rendered = render(
123
+ <LineageGroupNode
124
+ id="legacy-group"
125
+ data={{
126
+ designVariant: "classic",
127
+ label: "Legacy",
128
+ variant: "absolute",
129
+ }}
130
+ />,
131
+ );
132
+
133
+ expect(
134
+ rendered.container.querySelector(
135
+ ".td-lineage-group-node--design-classic",
136
+ ),
137
+ ).toBeInTheDocument();
138
+ });
139
+
140
+ it("keeps the active modifier used by the selected emphasis state", () => {
141
+ const rendered = render(
142
+ <LineageGroupNode
143
+ id="selected-group"
144
+ data={{
145
+ designVariant: "redesigned",
146
+ isActive: true,
147
+ label: "Selected group",
148
+ variant: "absolute",
149
+ }}
150
+ />,
151
+ );
152
+
153
+ expect(
154
+ rendered.container.querySelector(
155
+ ".td-lineage-group-node--design-redesigned.td-lineage-group-node--active",
156
+ ),
157
+ ).toBeInTheDocument();
158
+ });
159
+
160
+ it("marks the group under the pointer independently from related nodes", () => {
161
+ const rendered = render(
162
+ <EdgeHoverContext.Provider value={{ hoveredNodeId: "hovered-group" }}>
163
+ <LineageGroupNode
164
+ id="hovered-group"
165
+ data={{
166
+ designVariant: "redesigned",
167
+ label: "Hovered group",
168
+ variant: "absolute",
169
+ }}
170
+ />
171
+ </EdgeHoverContext.Provider>,
172
+ );
173
+
174
+ expect(
175
+ rendered.container.querySelector(
176
+ ".td-lineage-group-node--hovered:not(.td-lineage-group-node--connected)",
177
+ ),
178
+ ).toBeInTheDocument();
179
+ });
180
+
181
+ it("renders the oversized warning badge only for oversized groups", () => {
182
+ const rendered = render(
183
+ <LineageGroupNode
184
+ id="oversized-group"
185
+ data={{
186
+ designVariant: "redesigned",
187
+ label: "Oversized group",
188
+ oversized: true,
189
+ variant: "absolute",
190
+ }}
191
+ />,
192
+ );
193
+
194
+ expect(
195
+ rendered.container.querySelector(
196
+ ".td-lineage-group-node__oversized-warning",
197
+ ),
198
+ ).toBeInTheDocument();
199
+ });
200
+
201
+ it("renders the oversized warning title band and message", () => {
202
+ const rendered = render(
203
+ <LineageGroupNode
204
+ id="oversized-group"
205
+ data={{
206
+ designVariant: "redesigned",
207
+ label: "Oversized group",
208
+ oversized: true,
209
+ oversizedWarningOpen: true,
210
+ variant: "absolute",
211
+ }}
212
+ />,
213
+ );
214
+
215
+ expect(rendered.getByText("Warning")).toBeInTheDocument();
216
+ expect(rendered.getByText(/more than 20 nodes/)).toBeInTheDocument();
217
+ });
218
+
219
+ it("omits the oversized badge for regular groups", () => {
220
+ const rendered = render(
221
+ <LineageGroupNode
222
+ id="regular-group"
223
+ data={{
224
+ designVariant: "redesigned",
225
+ label: "Regular group",
226
+ variant: "absolute",
227
+ }}
228
+ />,
229
+ );
230
+
231
+ expect(
232
+ rendered.container.querySelector(
233
+ ".td-lineage-group-node__oversized-warning",
234
+ ),
235
+ ).not.toBeInTheDocument();
236
+ });
237
+
238
+ it("offers the Open link when the oversized warning was triggered", () => {
239
+ const onConfirmOversizedExpand = jest.fn();
240
+ const rendered = render(
241
+ <LineageGroupNode
242
+ id="blocked-group"
243
+ data={{
244
+ designVariant: "redesigned",
245
+ label: "Blocked group",
246
+ onConfirmOversizedExpand,
247
+ oversized: true,
248
+ oversizedWarningOpen: true,
249
+ variant: "absolute",
250
+ }}
251
+ />,
252
+ );
253
+
254
+ const openButton = rendered.getByRole("button", { name: "Open" });
255
+ expect(openButton).toBeInTheDocument();
256
+ fireEvent.click(openButton);
257
+ expect(onConfirmOversizedExpand).toHaveBeenCalled();
258
+ });
259
+ });
260
+
261
+ describe("lineageNodeIcon", () => {
262
+ it.each([
263
+ [{ kind: "resource" }, "table"],
264
+ [{ kind: "process" }, "random"],
265
+ [{ kind: "group" }, "database"],
266
+ [{ kind: "group", z: 1 }, "server"],
267
+ [{ kind: "group", z: 2 }, "database"],
268
+ [{ kind: "group", resourceContainer: true, z: 1 }, "table"],
269
+ ])("resolves %p as %s", (node, icon) => {
270
+ expect(lineageNodeIcon(node)).toBe(icon);
271
+ });
272
+ });
273
+
274
+ it.each(["direct", "contained"])(
275
+ "distinguishes %s query origins in collapsed groups",
276
+ (queryOrigin) => {
277
+ const rendered = render(
278
+ <EdgeHoverContext.Provider value={{ hoveredNodeId: "group" }}>
279
+ <LineageGroupNode
280
+ id="group"
281
+ data={{
282
+ label: "Warehouse",
283
+ variant: "absolute",
284
+ designVariant: "redesigned",
285
+ collapsed: true,
286
+ isActive: true,
287
+ queryOrigin,
288
+ }}
289
+ />
290
+ </EdgeHoverContext.Provider>,
291
+ { locale: "es" },
292
+ );
293
+ expect(
294
+ rendered.getByText(
295
+ queryOrigin === "direct"
296
+ ? "Origen de consulta"
297
+ : "Contiene origen de consulta",
298
+ ),
299
+ ).toBeInTheDocument();
300
+ expect(
301
+ rendered.container.querySelector(".td-lineage-group-node"),
302
+ ).toHaveClass(
303
+ "td-lineage-group-node--query-origin",
304
+ "td-lineage-group-node--active",
305
+ "td-lineage-group-node--hovered",
306
+ );
307
+ },
308
+ );
@@ -0,0 +1,23 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<ConceptNode /> matches the snapshot 1`] = `
4
+ <div>
5
+ <div
6
+ class="td-concept-node td-concept-node--active"
7
+ >
8
+ <span
9
+ data-position="left"
10
+ data-testid="handle-target"
11
+ />
12
+ <span
13
+ class="td-concept-node__label"
14
+ >
15
+ Snapshot
16
+ </span>
17
+ <span
18
+ data-position="right"
19
+ data-testid="handle-source"
20
+ />
21
+ </div>
22
+ </div>
23
+ `;