diff --git a/js/packages/ui/src/components/lineage/LineageViewOss.tsx b/js/packages/ui/src/components/lineage/LineageViewOss.tsx index ae4c1d59c..5e55b2e4f 100644 --- a/js/packages/ui/src/components/lineage/LineageViewOss.tsx +++ b/js/packages/ui/src/components/lineage/LineageViewOss.tsx @@ -8,11 +8,6 @@ import { createLineageDiffCheck, createSchemaDiffCheck, getCll, - isHistogramDiffRun, - isProfileDiffRun, - isTopKDiffRun, - isValueDiffDetailRun, - isValueDiffRun, type LineageDiffViewOptions, select, } from "../../api"; @@ -125,7 +120,10 @@ import { LineageLegend } from "./legend"; import { toReactFlow } from "./lineage"; import { NodeViewOss as NodeView } from "./NodeViewOss"; import type { NodeChangeStatus } from "./nodes/LineageNode"; -import { shouldCloseOrphanedRunResult } from "./runResultVisibility"; +import { + isNodeBoundRunResult, + shouldCloseOrphanedRunResult, +} from "./runResultVisibility"; import SetupConnectionBanner from "./SetupConnectionBannerOss"; import { BaseEnvironmentSetupNotification } from "./SingleEnvironmentQueryView"; import { @@ -1195,16 +1193,9 @@ export function PrivateLineageView( return; } - let selectedRunModel = undefined; - if ( - isTopKDiffRun(run) || - isProfileDiffRun(run) || - isHistogramDiffRun(run) || - isValueDiffRun(run) || - isValueDiffDetailRun(run) - ) { - selectedRunModel = run.params?.model; - } + const selectedRunModel = isNodeBoundRunResult(run) + ? run.params?.model + : undefined; const intentKey = JSON.stringify([ runId ?? run.run_id, diff --git a/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts b/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts index c7e5dbba2..d23e910bd 100644 --- a/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts +++ b/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts @@ -7,8 +7,10 @@ import { } from "../runResultVisibility"; // Minimal stand-ins carrying only the fields the predicates / guard read. -const profileRun = (model: string) => +const profileDiffRun = (model: string) => ({ type: "profile_diff", params: { model } }) as unknown as Run; +const profileRun = (model: string) => + ({ type: "profile", params: { model } }) as unknown as Run; const rowCountRun = () => ({ type: "row_count_diff", params: {} }) as unknown as Run; const node = (name: string) => @@ -20,6 +22,10 @@ const node = (name: string) => describe("isNodeBoundRunResult", () => { it("is true for a node-bound run (profile_diff)", () => { + expect(isNodeBoundRunResult(profileDiffRun("customers"))).toBe(true); + }); + + it("is true for a single-environment profile run", () => { expect(isNodeBoundRunResult(profileRun("customers"))).toBe(true); }); @@ -35,13 +41,15 @@ describe("isNodeBoundRunResult", () => { describe("shouldCloseOrphanedRunResult", () => { it("closes when the run's model is absent from a built graph", () => { expect( - shouldCloseOrphanedRunResult(profileRun("customers"), [node("orders")]), + shouldCloseOrphanedRunResult(profileDiffRun("customers"), [ + node("orders"), + ]), ).toBe(true); }); it("keeps the result open when the run's model is present in the graph", () => { expect( - shouldCloseOrphanedRunResult(profileRun("customers"), [ + shouldCloseOrphanedRunResult(profileDiffRun("customers"), [ node("customers"), ]), ).toBe(false); @@ -58,7 +66,7 @@ describe("shouldCloseOrphanedRunResult", () => { // shut and the page shows bare lineage ("Ready to run query"), flaky across // reloads. Once nodes exist, an absent model closes the pane as before. it("does NOT close while the lineage graph has not been built yet (no nodes)", () => { - expect(shouldCloseOrphanedRunResult(profileRun("customers"), [])).toBe( + expect(shouldCloseOrphanedRunResult(profileDiffRun("customers"), [])).toBe( false, ); }); diff --git a/js/packages/ui/src/components/lineage/runResultVisibility.ts b/js/packages/ui/src/components/lineage/runResultVisibility.ts index 86428a905..22b7e6788 100644 --- a/js/packages/ui/src/components/lineage/runResultVisibility.ts +++ b/js/packages/ui/src/components/lineage/runResultVisibility.ts @@ -1,11 +1,4 @@ -import { - isHistogramDiffRun, - isProfileDiffRun, - isTopKDiffRun, - isValueDiffDetailRun, - isValueDiffRun, - type Run, -} from "../../api"; +import type { Run } from "../../api"; import { isLineageGraphNode, type LineageGraphNodes, @@ -15,6 +8,7 @@ import { type NodeBoundRun = Run & { type: | "top_k_diff" + | "profile" | "profile_diff" | "histogram_diff" | "value_diff" @@ -22,11 +16,11 @@ type NodeBoundRun = Run & { }; /** - * A run result whose content is bound to a specific model node. Profile / - * value / top-k / histogram / value-detail diffs render inside that model's - * NodeView, so they only make sense while the model is part of the rendered - * lineage. (row_count / query / query_diff results are NOT node-bound — they - * render in their own pane regardless of the visible nodes.) + * A run result whose content is bound to a specific model node. Profile, + * value, top-k, histogram, and value-detail results carry a single model, so + * they only make sense while that model is part of the rendered lineage. + * (row_count / query / query_diff results are NOT node-bound — they render in + * their own pane regardless of the visible nodes.) * * Typed as a type guard so callers narrow `run` to `NodeBoundRun` and can read * `run.params?.model` without casting away the discriminated-union typing. @@ -34,14 +28,17 @@ type NodeBoundRun = Run & { export function isNodeBoundRunResult( run: Run | undefined, ): run is NodeBoundRun { - return ( - !!run && - (isTopKDiffRun(run) || - isProfileDiffRun(run) || - isHistogramDiffRun(run) || - isValueDiffRun(run) || - isValueDiffDetailRun(run)) - ); + switch (run?.type) { + case "top_k_diff": + case "profile": + case "profile_diff": + case "histogram_diff": + case "value_diff": + case "value_diff_detail": + return true; + default: + return false; + } } /** diff --git a/js/src/components/lineage/__tests__/LineageView.component.test.tsx b/js/src/components/lineage/__tests__/LineageView.component.test.tsx index 361def81b..071e51d5d 100644 --- a/js/src/components/lineage/__tests__/LineageView.component.test.tsx +++ b/js/src/components/lineage/__tests__/LineageView.component.test.tsx @@ -38,7 +38,11 @@ if (typeof Object.groupBy === "undefined") { } import type { LineageGraph, LineageGraphNode } from "@datarecce/ui"; -import type { ColumnLineageData, ServerInfoResult } from "@datarecce/ui/api"; +import type { + ColumnLineageData, + Run, + ServerInfoResult, +} from "@datarecce/ui/api"; import { act, fireEvent, @@ -193,19 +197,9 @@ vi.mock("@xyflow/react", () => ({ // Mock @datarecce/ui contexts const mockRefetchLineageGraph = vi.fn(); const mockRefetchRunsAggregated = vi.fn(); -let mockActiveRun: - | { - type: "schema_diff" | "profile_diff"; - run_id: string; - run_at: string; - params?: { - model: string; - }; - } - | undefined; +let mockActiveRun: Run | undefined; let mockActiveRunId: string | undefined; let mockIsRunResultOpen = false; -let mockIsProfileDiffRun = false; const mockLineageGraphContext = { lineageGraph: undefined as LineageGraph | undefined, @@ -326,11 +320,6 @@ vi.mock("@datarecce/ui/api", () => ({ select: vi.fn().mockResolvedValue({ nodes: [] }), createLineageDiffCheck: vi.fn().mockResolvedValue({ check_id: "test-check" }), createSchemaDiffCheck: vi.fn().mockResolvedValue({ check_id: "test-check" }), - isHistogramDiffRun: vi.fn(() => false), - isProfileDiffRun: vi.fn(() => mockIsProfileDiffRun), - isTopKDiffRun: vi.fn(() => false), - isValueDiffDetailRun: vi.fn(() => false), - isValueDiffRun: vi.fn(() => false), })); // Mock @datarecce/ui/components/lineage @@ -883,16 +872,41 @@ function setupWithLineageGraph(lineageGraph?: LineageGraph) { }); } -function setupOpenProfileRun(model: string) { - mockActiveRunId = `profile-${model}`; +type ModelScopedRunType = + | "profile" + | "profile_diff" + | "top_k_diff" + | "histogram_diff" + | "value_diff" + | "value_diff_detail"; + +function setupOpenModelRun(type: ModelScopedRunType, model: string) { + mockActiveRunId = `${type}-${model}`; mockIsRunResultOpen = true; - mockIsProfileDiffRun = true; mockActiveRun = { - type: "profile_diff", + type, run_id: mockActiveRunId, run_at: "2026-07-28T00:00:00Z", params: { model }, - }; + } as Run; +} + +function setupOpenProfileRun(model: string) { + setupOpenModelRun("profile_diff", model); +} + +function setupOpenRowCountRun( + type: "row_count" | "row_count_diff", + model: string, +) { + mockActiveRunId = `${type}-${model}`; + mockIsRunResultOpen = true; + mockActiveRun = { + type, + run_id: mockActiveRunId, + run_at: "2026-07-28T00:00:00Z", + params: { node_names: [model] }, + } as Run; } // ============================================================================ @@ -937,7 +951,6 @@ describe("LineageView Component", () => { mockActiveRun = undefined; mockActiveRunId = undefined; mockIsRunResultOpen = false; - mockIsProfileDiffRun = false; // Reset node state mock mockUseNodesStateReturnValue = [[], vi.fn(), vi.fn()]; @@ -1783,6 +1796,94 @@ describe("LineageView Component", () => { }); }); + describe("run focus synchronization", () => { + it.each([ + "profile", + "profile_diff", + "top_k_diff", + "histogram_diff", + "value_diff", + "value_diff_detail", + ] as const)( + "detaches an open %s result when the user focuses another node", + async (runType) => { + const lineageGraph = createMockLineageGraph(); + setupWithLineageGraph(lineageGraph); + setupOpenModelRun(runType, "node1"); + + render( + + + , + ); + + await waitFor(() => + expect(screen.getByTestId("node-view")).toHaveAttribute( + "data-node-id", + "model.test.node1", + ), + ); + + fireEvent.click(screen.getByTestId("click-model.test.node2")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByTestId("node-view")).toHaveAttribute( + "data-node-id", + "model.test.node2", + ); + expect(mockCloseRunResult).not.toHaveBeenCalled(); + }, + ); + + it.each(["row_count", "row_count_diff"] as const)( + "keeps node navigation independent of an open %s result", + async (runType) => { + const lineageGraph = createMockLineageGraph(); + setupWithLineageGraph(lineageGraph); + const view = () => ( + + + + ); + const { rerender } = render(view()); + + await waitFor(() => + expect( + screen.getByTestId("click-model.test.node1"), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByTestId("click-model.test.node1")); + expect(screen.getByTestId("node-view")).toHaveAttribute( + "data-node-id", + "model.test.node1", + ); + + setupOpenRowCountRun(runType, "node1"); + rerender(view()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(screen.getByTestId("node-view")).toHaveAttribute( + "data-node-id", + "model.test.node1", + ); + + fireEvent.click(screen.getByTestId("click-model.test.node2")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByTestId("node-view")).toHaveAttribute( + "data-node-id", + "model.test.node2", + ); + expect(mockCloseRunResult).not.toHaveBeenCalled(); + }, + ); + }); + describe("async layout ownership", () => { it("invalidates a deferred CLL request on unmount before it patches or lays out", async () => { const lineageGraph = createMockLineageGraph();