@truedat/dd 8.14.0 → 8.14.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,18 +1,53 @@
1
- import { act, fireEvent, screen, within } from "@testing-library/react";
1
+ import {
2
+ act,
3
+ fireEvent,
4
+ screen,
5
+ waitFor,
6
+ within,
7
+ } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
2
9
  import { Route, Routes, useLocation } from "react-router";
3
10
  import { render } from "@truedat/test/render";
4
11
  import { LineageGraph } from "../LineageGraph";
5
12
  import { GraphLoader } from "../GraphLoader";
6
13
 
7
14
  let mockGraphProps;
15
+ let mockHoverContext;
8
16
  let mockDeferInitialFit = false;
9
17
 
18
+ /* eslint-disable react/prop-types -- Minimal test double for Semantic UI Popup. */
19
+ jest.mock("semantic-ui-react", () => {
20
+ const React = require("react");
21
+ const semanticUi = jest.requireActual("semantic-ui-react");
22
+
23
+ return {
24
+ ...semanticUi,
25
+ Popup: ({ className, content, onOpen, open, trigger }) => (
26
+ <>
27
+ {React.cloneElement(trigger, {
28
+ onClick: (event) => {
29
+ trigger.props.onClick?.(event);
30
+ onOpen?.(event);
31
+ },
32
+ })}
33
+ {open ? <div className={className}>{content}</div> : null}
34
+ </>
35
+ ),
36
+ };
37
+ });
38
+ /* eslint-enable react/prop-types */
39
+
10
40
  jest.mock("@truedat/core/components/Graph", () => {
11
41
  const React = require("react");
12
42
  const { createPortal } = require("react-dom");
13
43
  return function Graph(props) {
14
44
  mockGraphProps = props;
15
45
  const [expanded, setExpanded] = React.useState(false);
46
+ const {
47
+ EdgeHoverContext,
48
+ } = require("@truedat/core/components/graph/ColoredEdge");
49
+ const hover = React.useContext(EdgeHoverContext);
50
+ mockHoverContext = hover;
16
51
  React.useEffect(() => {
17
52
  if (!mockDeferInitialFit) props.onInitialFitComplete();
18
53
  }, [props.onInitialFitComplete]);
@@ -30,6 +65,9 @@ jest.mock("@truedat/core/components/Graph", () => {
30
65
  {node.id}
31
66
  </button>
32
67
  ))}
68
+ <button className="react-flow__pane" onClick={props.onPaneClick}>
69
+ Canvas
70
+ </button>
33
71
  {props.topControls}
34
72
  {props.afterControls}
35
73
  </div>
@@ -50,6 +88,7 @@ const props = {
50
88
  graphId: "graph-1",
51
89
  loading: false,
52
90
  simplified: true,
91
+ cancelPoll: jest.fn(),
53
92
  groups: [],
54
93
  resources: [
55
94
  { id: "orders", name: "Orders", x: 0, y: 0, w: 288, h: 100 },
@@ -273,6 +312,7 @@ it.each([
273
312
  graph={graph}
274
313
  fetchGraph={fetchGraph}
275
314
  clearGraph={jest.fn()}
315
+ cancelPoll={jest.fn()}
276
316
  />
277
317
  }
278
318
  />
@@ -284,6 +324,226 @@ it.each([
284
324
  },
285
325
  );
286
326
 
327
+ it("changes dependency layout without clearing selection and transfers it to the module", async () => {
328
+ const user = userEvent.setup({ delay: null });
329
+ renderGraph();
330
+ await user.click(screen.getByRole("button", { name: "orders", exact: true }));
331
+ const toggle = screen.getByRole("button", {
332
+ name: "Switch to expanded view",
333
+ });
334
+ await user.click(toggle);
335
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
336
+ "Orders",
337
+ );
338
+ expect(global.localStorage.getItem("td.lineage.dependencyLayout.v1")).toBe(
339
+ "expanded",
340
+ );
341
+ expect(
342
+ screen.getByRole("button", { name: "Switch to compact view" }),
343
+ ).toHaveFocus();
344
+ fireEvent.click(
345
+ screen.getByRole("button", { name: "View in Lineage module" }),
346
+ );
347
+ expect(
348
+ JSON.parse(screen.getByTestId("location").textContent).state
349
+ .lineageViewState,
350
+ ).toMatchObject({
351
+ dependencyLayout: "expanded",
352
+ selectedGraphNodeId: "orders",
353
+ });
354
+ global.localStorage.removeItem("td.lineage.dependencyLayout.v1");
355
+ });
356
+
357
+ it("keeps node keyboard focus independent of hover across normal and expanded canvases", async () => {
358
+ renderGraph();
359
+ const node = screen.getByRole("button", { name: "orders", exact: true });
360
+ act(() => node.focus());
361
+ expect(mockHoverContext.keyboardFocusedNodeId).toBe("orders");
362
+ expect(mockHoverContext.hoveredNodeId).toBeNull();
363
+ act(() => node.blur());
364
+ expect(mockHoverContext.keyboardFocusedNodeId).toBeNull();
365
+ fireEvent.click(screen.getByText("Toggle expanded"));
366
+ const expandedNode = within(screen.getByTestId("expanded")).getByRole(
367
+ "button",
368
+ { name: "report", exact: true },
369
+ );
370
+ act(() => expandedNode.focus());
371
+ await waitFor(() =>
372
+ expect(mockHoverContext.keyboardFocusedNodeId).toBe("report"),
373
+ );
374
+ act(() => expandedNode.blur());
375
+ await waitFor(() =>
376
+ expect(mockHoverContext.keyboardFocusedNodeId).toBeNull(),
377
+ );
378
+ });
379
+
380
+ it("keeps an active selection while changing depth from the popup", async () => {
381
+ const user = userEvent.setup({ delay: null });
382
+ renderGraph({
383
+ groups: [
384
+ { id: "database", name: "Database", x: -20, y: -60, w: 800, h: 220 },
385
+ ],
386
+ });
387
+ await user.click(screen.getByRole("button", { name: "orders", exact: true }));
388
+ fireEvent.click(screen.getByRole("button", { name: "Visible levels" }));
389
+ const slider = await screen.findByRole("slider", { name: "Visible levels" });
390
+
391
+ fireEvent.mouseDown(slider);
392
+ fireEvent.change(slider, { target: { value: "0" } });
393
+
394
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
395
+ "Orders",
396
+ );
397
+ expect(mockHoverContext.compactReferenceNodeId).toBe("orders");
398
+ });
399
+
400
+ it("closes only the depth popup with Escape and restores trigger focus", async () => {
401
+ const user = userEvent.setup({ delay: null });
402
+ renderGraph({
403
+ groups: [
404
+ { id: "database", name: "Database", x: -20, y: -60, w: 800, h: 220 },
405
+ ],
406
+ });
407
+ await user.click(screen.getByRole("button", { name: "orders", exact: true }));
408
+ const depthButton = screen.getByRole("button", { name: "Visible levels" });
409
+ fireEvent.click(depthButton);
410
+ const slider = await screen.findByRole("slider", { name: "Visible levels" });
411
+
412
+ await user.type(slider, "{Escape}");
413
+
414
+ expect(
415
+ screen.queryByRole("dialog", { name: "Visible levels" }),
416
+ ).not.toBeInTheDocument();
417
+ expect(screen.getByLabelText("Lineage inspector")).toHaveTextContent(
418
+ "Orders",
419
+ );
420
+ expect(mockHoverContext.compactReferenceNodeId).toBe("orders");
421
+ expect(depthButton).toHaveFocus();
422
+ });
423
+
424
+ it.each(["outside", "canvas"])(
425
+ "minimizes before deselecting on successive %s clicks",
426
+ (target) => {
427
+ renderGraph();
428
+ fireEvent.click(
429
+ screen.getByRole("button", { name: "orders", exact: true }),
430
+ );
431
+ const clickOutside = () => {
432
+ const element =
433
+ target === "outside" ? document.body : screen.getByText("Canvas");
434
+ fireEvent.mouseDown(element);
435
+ fireEvent.click(element);
436
+ };
437
+ clickOutside();
438
+ expect(
439
+ screen.queryByLabelText("Lineage inspector"),
440
+ ).not.toBeInTheDocument();
441
+ expect(mockHoverContext.compactReferenceNodeId).toBe("orders");
442
+ clickOutside();
443
+ expect(mockHoverContext.compactReferenceNodeId).toBeFalsy();
444
+ },
445
+ );
446
+
447
+ it("keeps compact dependencies referenced to the visible pin after clearing selection", () => {
448
+ renderGraph();
449
+ fireEvent.click(screen.getByRole("button", { name: "orders", exact: true }));
450
+ fireEvent.click(screen.getByRole("button", { name: "Pin focus" }));
451
+ fireEvent.click(screen.getByRole("button", { name: "report", exact: true }));
452
+
453
+ expect(mockHoverContext.compactReferenceNodeId).toBe("orders");
454
+
455
+ fireEvent.click(screen.getByText("Canvas"));
456
+ fireEvent.click(screen.getByText("Canvas"));
457
+
458
+ expect(screen.queryByLabelText("Lineage inspector")).not.toBeInTheDocument();
459
+ expect(mockHoverContext.compactReferenceNodeId).toBe("orders");
460
+ expect(mockHoverContext.selectedEdgeId).toBeNull();
461
+ });
462
+
463
+ it("synchronizes global depth and retains it after manual group changes", () => {
464
+ renderGraph({
465
+ groups: [
466
+ { id: "database", name: "Database", x: -20, y: -60, w: 800, h: 220 },
467
+ ],
468
+ });
469
+ const toolbar = () => mockGraphProps.topControls.props;
470
+ act(() => toolbar().onExpandAll());
471
+ expect(toolbar().expansionDepth).toBe(toolbar().maxExpansionDepth);
472
+ expect(toolbar().expansionDepthCustom).toBe(false);
473
+ const lastDepth = toolbar().expansionDepth;
474
+ const toggleGroup = () =>
475
+ mockGraphProps.nodes
476
+ .find((node) => node.data?.onToggleCollapse)
477
+ .data.onToggleCollapse();
478
+ act(toggleGroup);
479
+ expect(toolbar().expansionDepth).toBe(lastDepth);
480
+ expect(toolbar().expansionDepthCustom).toBe(true);
481
+ act(toggleGroup);
482
+ expect(toolbar().expansionDepthCustom).toBe(true);
483
+ act(() => toolbar().onCollapseAll());
484
+ expect(toolbar().expansionDepth).toBe(0);
485
+ expect(toolbar().expansionDepthCustom).toBe(false);
486
+ act(toggleGroup);
487
+ expect(toolbar().expansionDepth).toBe(0);
488
+ expect(toolbar().expansionDepthCustom).toBe(true);
489
+ act(() => toolbar().onExpansionDepthChange(1));
490
+ expect(toolbar().expansionDepth).toBe(1);
491
+ expect(toolbar().expansionDepthCustom).toBe(false);
492
+ });
493
+
494
+ it.each([true, false])(
495
+ "marks search expansion as custom only when it opens ancestors: %s",
496
+ (collapsed) => {
497
+ renderGraph({
498
+ groups: [
499
+ { id: "database", name: "Database", x: -20, y: -60, w: 800, h: 220 },
500
+ ],
501
+ });
502
+ const toolbar = () => mockGraphProps.topControls.props;
503
+ act(() =>
504
+ collapsed ? toolbar().onCollapseAll() : toolbar().onExpandAll(),
505
+ );
506
+ const depth = toolbar().expansionDepth;
507
+ act(() => toolbar().onSearchResultSelect("orders"));
508
+ expect(
509
+ mockGraphProps.nodes.find((node) => node.id === "orders").hidden,
510
+ ).toBeFalsy();
511
+ expect(toolbar().expansionDepth).toBe(depth);
512
+ expect(toolbar().expansionDepthCustom).toBe(collapsed);
513
+ },
514
+ );
515
+
516
+ it("refits after window resizing settles and cancels pending work on unmount", () => {
517
+ jest.useFakeTimers();
518
+ try {
519
+ const { unmount } = renderGraph();
520
+ const toolbar = () => mockGraphProps.topControls.props;
521
+ act(() => toolbar().onSearchResultSelect("orders"));
522
+ expect(mockGraphProps.graphOptions.focusNodeId).toBe("orders");
523
+ const initialKey = mockGraphProps.graphOptions.fitViewRequestKey;
524
+ act(() => {
525
+ fireEvent(window, new Event("resize"));
526
+ jest.advanceTimersByTime(50);
527
+ fireEvent(window, new Event("resize"));
528
+ jest.advanceTimersByTime(79);
529
+ });
530
+ expect(mockGraphProps.graphOptions.fitViewRequestKey).toBe(initialKey);
531
+ act(() => jest.advanceTimersByTime(1));
532
+ expect(mockGraphProps.graphOptions.fitViewRequestKey).toBe(
533
+ (initialKey || 0) + 1,
534
+ );
535
+ expect(mockGraphProps.graphOptions.focusNodeId).toBeNull();
536
+ expect(toolbar().context.node.graphNodeId).toBe("orders");
537
+ fireEvent(window, new Event("resize"));
538
+ unmount();
539
+ expect(jest.getTimerCount()).toBe(0);
540
+ fireEvent(window, new Event("resize"));
541
+ expect(jest.getTimerCount()).toBe(0);
542
+ } finally {
543
+ jest.useRealTimers();
544
+ }
545
+ });
546
+
287
547
  test.each([
288
548
  { simplified: true },
289
549
  { simplified: false },
@@ -292,18 +552,23 @@ test.each([
292
552
  generationJob: { requestId: "other", status: "pending" },
293
553
  },
294
554
  ])("keeps loading inline without a background action: %j", (extra) => {
555
+ jest.useFakeTimers();
295
556
  const backgroundGraph = jest.fn();
296
557
  const view = renderGraph({ ...extra, loading: true, backgroundGraph });
297
- act(() => jest.advanceTimersByTime(3000));
298
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
299
- expect(
300
- screen.getByText("lineage.graph.loading").closest("[role=status]"),
301
- ).toBeInTheDocument();
302
- expect(
303
- screen.queryByRole("button", { name: "lineage.graph.wait.background" }),
304
- ).not.toBeInTheDocument();
305
- fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
306
- expect(backgroundGraph).not.toHaveBeenCalled();
307
- expect(screen.getByText("lineage.graph.loading")).toBeInTheDocument();
308
- view.unmount();
558
+ try {
559
+ act(() => jest.advanceTimersByTime(3000));
560
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
561
+ expect(
562
+ screen.getByText("lineage.graph.loading").closest("[role=status]"),
563
+ ).toBeInTheDocument();
564
+ expect(
565
+ screen.queryByRole("button", { name: "lineage.graph.wait.background" }),
566
+ ).not.toBeInTheDocument();
567
+ fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
568
+ expect(backgroundGraph).not.toHaveBeenCalled();
569
+ expect(screen.getByText("lineage.graph.loading")).toBeInTheDocument();
570
+ } finally {
571
+ view.unmount();
572
+ jest.useRealTimers();
573
+ }
309
574
  });
@@ -363,6 +363,50 @@ describe("visibleLegendKeys", () => {
363
363
  });
364
364
  });
365
365
 
366
+ it.each([
367
+ [
368
+ "compact",
369
+ "expanded",
370
+ "Switch to expanded view",
371
+ "Expanded view",
372
+ ".lucide-columns-3",
373
+ ],
374
+ [
375
+ "expanded",
376
+ "compact",
377
+ "Switch to compact view",
378
+ "Compact view",
379
+ ".lucide-columns-2",
380
+ ],
381
+ ])(
382
+ "toggles the %s view directly and explains it on focus",
383
+ async (mode, next, label, title, icon) => {
384
+ const onDependencyLayoutChange = jest.fn();
385
+ renderToolbar({
386
+ simplified: true,
387
+ dependencyLayout: mode,
388
+ onDependencyLayoutChange,
389
+ });
390
+ expect(
391
+ screen.queryByRole("button", { name: "Graph settings" }),
392
+ ).not.toBeInTheDocument();
393
+ const toggle = screen.getByRole("button", { name: label });
394
+ expect(toggle.querySelector(icon)).toBeInTheDocument();
395
+ await act(async () => fireEvent.focus(toggle));
396
+ expect(await screen.findByText(title)).toBeInTheDocument();
397
+ expect(
398
+ screen.getByText(
399
+ next === "expanded"
400
+ ? "Shows internal dependencies between nodes in each group."
401
+ : "Reduces visual clutter. Select a node to explore its internal dependencies.",
402
+ ),
403
+ ).toBeInTheDocument();
404
+ await act(async () => fireEvent.click(toggle));
405
+ expect(onDependencyLayoutChange).toHaveBeenCalledWith(next);
406
+ expect(screen.queryByRole("radio")).not.toBeInTheDocument();
407
+ },
408
+ );
409
+
366
410
  it("shows the current selection without an additional pinned chip", () => {
367
411
  const onPinnedContextSelect = jest.fn();
368
412
  const rendered = renderToolbar({
@@ -414,3 +458,45 @@ it("preserves the structure icon in the pinned header without an unpin button",
414
458
  bar.querySelector(".lineage-graph__dock-context-unpin"),
415
459
  ).not.toBeInTheDocument();
416
460
  });
461
+
462
+ it("places the layout toggle after settings and removes layout radios from settings", async () => {
463
+ const { container } = renderToolbar({ onDependencyLayoutChange: jest.fn() });
464
+ const settings = screen.getByRole("button", { name: "Graph settings" });
465
+ const toggle = screen.getByRole("button", {
466
+ name: "Switch to expanded view",
467
+ });
468
+ const buttons = Array.from(container.querySelectorAll("button"));
469
+ expect(buttons.indexOf(toggle)).toBe(buttons.indexOf(settings) + 1);
470
+ await act(async () => fireEvent.click(settings));
471
+ expect(
472
+ screen.getByRole("dialog", { name: "Graph settings" }),
473
+ ).toBeInTheDocument();
474
+ expect(screen.queryByRole("radio")).not.toBeInTheDocument();
475
+ });
476
+
477
+ it("keeps the selected depth and marks individual expansion with a question mark", async () => {
478
+ const onExpansionDepthChange = jest.fn();
479
+ renderToolbar({
480
+ expansionDepth: 2,
481
+ maxExpansionDepth: 4,
482
+ expansionDepthCustom: true,
483
+ onExpansionDepthChange,
484
+ });
485
+ await act(async () =>
486
+ fireEvent.click(screen.getByRole("button", { name: "Visible levels" })),
487
+ );
488
+ const slider = screen.getByRole("slider");
489
+ expect(slider).toHaveValue("2");
490
+ expect(slider).toHaveAttribute(
491
+ "aria-valuetext",
492
+ "Last selected level: 2. Nodes have been expanded or collapsed individually.",
493
+ );
494
+ expect(slider.closest(".lineage-graph__depth-popover")).toHaveClass(
495
+ "is-custom",
496
+ );
497
+ expect(screen.getByText("?")).toHaveClass(
498
+ "lineage-graph__depth-custom-marker",
499
+ );
500
+ fireEvent.change(slider, { target: { value: "3" } });
501
+ expect(onExpansionDepthChange).toHaveBeenCalledWith("3");
502
+ });
@@ -2844,6 +2844,41 @@ button.lineage-graph__dock-context-reopen {
2844
2844
  text-overflow: ellipsis;
2845
2845
  white-space: nowrap;
2846
2846
  }
2847
+ .lineage-graph__depth-slider-wrap {
2848
+ position: relative;
2849
+ display: flex;
2850
+ margin-top: 8px;
2851
+ }
2852
+ .lineage-graph__depth-custom-marker {
2853
+ position: absolute;
2854
+ top: -13px;
2855
+ transform: translateX(-50%);
2856
+ color: #64748b;
2857
+ font-size: 11px;
2858
+ font-weight: 700;
2859
+ line-height: 12px;
2860
+ pointer-events: none;
2861
+ }
2862
+ .lineage-graph__depth-popover.is-custom {
2863
+ .lineage-graph__depth-slider {
2864
+ accent-color: #64748b;
2865
+ }
2866
+ .lineage-graph__depth-popover-scale > span {
2867
+ background: #e2e8f0;
2868
+ color: #475569;
2869
+ }
2870
+ .lineage-graph__depth-popover-scale > .is-current {
2871
+ background: #64748b;
2872
+ color: #fff;
2873
+ }
2874
+ }
2875
+ .lineage-graph__stage-surface
2876
+ .td-graph-controls--vertical
2877
+ .lineage-graph__dock-depth-button
2878
+ .lineage-graph__dock-depth-value.is-custom {
2879
+ background: #e2e8f0;
2880
+ color: #475569;
2881
+ }
2847
2882
  .lineage-graph__depth-slider {
2848
2883
  width: 100%;
2849
2884
  height: 18px;