Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 7 additions & 16 deletions js/packages/ui/src/components/lineage/LineageViewOss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import {
createLineageDiffCheck,
createSchemaDiffCheck,
getCll,
isHistogramDiffRun,
isProfileDiffRun,
isTopKDiffRun,
isValueDiffDetailRun,
isValueDiffRun,
type LineageDiffViewOptions,
select,
} from "../../api";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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);
});

Expand All @@ -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);
Expand All @@ -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,
);
});
Expand Down
39 changes: 18 additions & 21 deletions js/packages/ui/src/components/lineage/runResultVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
isHistogramDiffRun,
isProfileDiffRun,
isTopKDiffRun,
isValueDiffDetailRun,
isValueDiffRun,
type Run,
} from "../../api";
import type { Run } from "../../api";
import {
isLineageGraphNode,
type LineageGraphNodes,
Expand All @@ -15,33 +8,37 @@ import {
type NodeBoundRun = Run & {
type:
| "top_k_diff"
| "profile"
| "profile_diff"
| "histogram_diff"
| "value_diff"
| "value_diff_detail";
};

/**
* 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.
*/
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;
}
}

/**
Expand Down
147 changes: 124 additions & 23 deletions js/src/components/lineage/__tests__/LineageView.component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

// ============================================================================
Expand Down Expand Up @@ -937,7 +951,6 @@ describe("LineageView Component", () => {
mockActiveRun = undefined;
mockActiveRunId = undefined;
mockIsRunResultOpen = false;
mockIsProfileDiffRun = false;

// Reset node state mock
mockUseNodesStateReturnValue = [[], vi.fn(), vi.fn()];
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoping note, not a change request. NodeViewOss is stubbed to render only data-node-id, so these assertions cover "focus moved and the run was not force-closed" — symptom (a). They are a proxy for symptom (b), "the profile diff pane empties": if a regression re-broke rendering inside the real NodeViewOss / ProfileDiffResultView while leaving closeRunResult uncalled, this suite would stay green.

A real content-persistence assertion would need heavier setup than this suite does anywhere else, so I would not add it here. Flagging it so the coverage is not read as stronger than it is.

async (runType) => {
const lineageGraph = createMockLineageGraph();
setupWithLineageGraph(lineageGraph);
setupOpenModelRun(runType, "node1");

render(
<TestWrapper>
<TestablePrivateLineageView interactive={true} ref={null} />
</TestWrapper>,
);

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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two row-count cases do not actually exercise isNodeBoundRunResult. LineageViewOss.tsx:1179 short-circuits row_count / row_count_diff out of the whole effect before the helper is ever consulted.

Verified by mutation: adding case "row_count": case "row_count_diff": to the switch in runResultVisibility.ts leaves this suite at PASS 8 / FAIL 0. The real protection for that behavior is the unit case at runResultVisibility.test.ts:32-34, which does fail under the same mutation.

So the PR description's claim — "treating row-count results as node-bound makes both row-count cases fail" — holds for the unit test but not for these two. Worth rewording the description, and possibly renaming this block to say what it verifies: that the early type-exclusion list keeps navigation independent. The tests themselves are fine and worth keeping.

async (runType) => {
const lineageGraph = createMockLineageGraph();
setupWithLineageGraph(lineageGraph);
const view = () => (
<TestWrapper>
<TestablePrivateLineageView interactive={true} ref={null} />
</TestWrapper>
);
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();
Expand Down
Loading