diff --git a/js/packages/ui/src/__tests__/nodeDetailsPublicTypes.test.ts b/js/packages/ui/src/__tests__/nodeDetailsPublicTypes.test.ts new file mode 100644 index 000000000..25e5bd5ee --- /dev/null +++ b/js/packages/ui/src/__tests__/nodeDetailsPublicTypes.test.ts @@ -0,0 +1,36 @@ +import type { + NodeDetailsOpenRequest as ContextsNodeDetailsOpenRequest, + NodeDetailsView as ContextsNodeDetailsView, +} from "../contexts-entry"; +import type { + NodeDetailsOpenRequest as RootNodeDetailsOpenRequest, + NodeDetailsView as RootNodeDetailsView, +} from "../index"; +import type { + NodeDetailsOpenRequest as TypesNodeDetailsOpenRequest, + NodeDetailsView as TypesNodeDetailsView, +} from "../types"; + +type PublicRequest = ContextsNodeDetailsOpenRequest & + RootNodeDetailsOpenRequest & + TypesNodeDetailsOpenRequest; +type PublicView = ContextsNodeDetailsView & + RootNodeDetailsView & + TypesNodeDetailsView; + +describe("public node-details contract", () => { + it("is nameable through the contexts, root, and type entrypoints", () => { + const view: PublicView = "analysis"; + const request: PublicRequest = { + nodeId: "model.test.orders", + view, + requestToken: 1, + }; + + expect(request).toEqual({ + nodeId: "model.test.orders", + view: "analysis", + requestToken: 1, + }); + }); +}); diff --git a/js/packages/ui/src/components/lineage/GraphNodeOss.tsx b/js/packages/ui/src/components/lineage/GraphNodeOss.tsx index 5bbf46254..0b5d6673a 100644 --- a/js/packages/ui/src/components/lineage/GraphNodeOss.tsx +++ b/js/packages/ui/src/components/lineage/GraphNodeOss.tsx @@ -18,12 +18,21 @@ import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Popper from "@mui/material/Popper"; +import Stack from "@mui/material/Stack"; import MuiTooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; import { type NodeProps, useStore } from "@xyflow/react"; -import { memo } from "react"; +import { memo, useCallback, useEffect, useId, useRef, useState } from "react"; import type { LineageGraphNode } from "../.."; import { COLUMN_HEIGHT } from "../.."; -import { isRowCountDiffRun, type RowCountDiff } from "../../api"; +import { + isRowCountDiffRun, + type NodeRunsAggregated, + type RowCountDiff, + type ValidationSummary, +} from "../../api"; import { useLineageGraphContext, useLineageViewContextSafe, @@ -101,6 +110,7 @@ function RowCountDiffTag({ sx={{ height: 20, fontSize: "0.7rem", + maxWidth: "100%", ...(directionStyle && { bgcolor: directionStyle.background, border: `1px solid ${directionStyle.border}`, @@ -111,30 +121,249 @@ function RowCountDiffTag({ ); } +function countLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count.toLocaleString()} ${count === 1 ? singular : plural}`; +} + +function validationSummaryLabel(summary: ValidationSummary) { + const resultLabel = countLabel(summary.result_count, "result"); + if (!summary.types.value_diff) { + return resultLabel; + } + return `${resultLabel} · ${countLabel(summary.types.value_diff.difference_count, "diff")}`; +} + +function hasPositiveValidationResultCount( + summary: ValidationSummary | undefined, +) { + const resultCount = summary?.result_count; + return ( + typeof resultCount === "number" && + Number.isFinite(resultCount) && + resultCount > 0 + ); +} + +function ValidationSummaryDetails({ summary }: { summary: ValidationSummary }) { + const { value_diff, profile_diff, top_k_diff, histogram_diff } = + summary.types; + + return ( + + {value_diff && ( + + + Value diff + + + {countLabel(1, "result")} ·{" "} + {countLabel(value_diff.difference_count, "diff")} + + + )} + {profile_diff && ( + + + Profile diff + + + {countLabel(profile_diff.result_count, "result")} + + + )} + {top_k_diff && ( + + + Top-k diff + + + {countLabel(top_k_diff.column_count, "column")} + + + )} + {histogram_diff && ( + + + Histogram diff + + + {countLabel(histogram_diff.column_count, "column")} + + + )} + + ); +} + +function ValidationSummaryChip({ + nodeId, + nodeName, + summary, + onOpenAnalysis, +}: { + nodeId: string; + nodeName: string; + summary: ValidationSummary; + onOpenAnalysis: (nodeId: string) => void; +}) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const closeTimerRef = useRef | undefined>( + undefined, + ); + const popoverId = useId(); + const label = validationSummaryLabel(summary); + + const cancelClose = useCallback(() => { + if (closeTimerRef.current !== undefined) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = undefined; + } + }, []); + const showPopover = useCallback(() => { + cancelClose(); + setOpen(true); + }, [cancelClose]); + const closePopover = useCallback(() => { + cancelClose(); + setOpen(false); + }, [cancelClose]); + const scheduleClose = useCallback(() => { + cancelClose(); + closeTimerRef.current = setTimeout(() => setOpen(false), 100); + }, [cancelClose]); + const scheduleCloseUnlessFocused = useCallback(() => { + if (document.activeElement === buttonRef.current) return; + scheduleClose(); + }, [scheduleClose]); + + useEffect(() => cancelClose, [cancelClose]); + + const handleTriggerKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Escape") { + event.preventDefault(); + closePopover(); + buttonRef.current?.focus(); + } + }; + const handlePopoverKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Escape") { + event.preventDefault(); + closePopover(); + buttonRef.current?.focus(); + } + }; + const stopPropagation = (event: React.SyntheticEvent) => + event.stopPropagation(); + + return ( + <> + { + event.stopPropagation(); + showPopover(); + onOpenAnalysis(nodeId); + }} + onDoubleClick={(event) => event.stopPropagation()} + onFocus={showPopover} + onKeyDown={handleTriggerKeyDown} + onKeyUp={stopPropagation} + onMouseEnter={showPopover} + onMouseLeave={scheduleCloseUnlessFocused} + onPointerDown={(event) => event.stopPropagation()} + sx={{ + alignItems: "center", + appearance: "none", + background: "transparent", + border: 0, + color: "inherit", + cursor: "pointer", + display: "flex", + flex: "0 1 auto", + margin: 0, + maxWidth: 150, + minHeight: 24, + minWidth: 0, + padding: "2px 0", + }} + > + + + {open && ( + + + + + + )} + + ); +} + /** * Node runs aggregated display component with OSS-specific icons * Shows schema diff indicator and row count diff for models */ function NodeRunsAggregatedDisplay({ - id, inverted, + nodeId, + nodeName, + runs, + schemaChanged, + showValidationSummary, + onOpenAnalysis, }: { - id: string; inverted: boolean; + nodeId: string; + nodeName: string; + runs: NodeRunsAggregated | undefined; + schemaChanged: boolean | undefined; + showValidationSummary: boolean; + onOpenAnalysis: (nodeId: string) => void; }) { - const { lineageGraph, runsAggregated } = useLineageGraphContext(); const { text, isDark } = useThemeColors(); - const runs = runsAggregated?.[id]; - const node = lineageGraph?.nodes[id]; - - if (!runs && !node) { - return null; - } - - let schemaChanged: boolean | undefined; - if (node?.data.change?.columns) { - schemaChanged = Object.keys(node.data.change.columns).length > 0; - } let rowCountChanged: boolean | undefined; if (runs?.row_count_diff) { @@ -155,7 +384,17 @@ function NodeRunsAggregatedDisplay({ const SchemaDiffIcon = findByRunType("schema_diff").icon; return ( - + {schemaChanged !== undefined && ( )} + {showValidationSummary && runs?.validation_summary && ( + + )} {runs?.row_count_diff && rowCountChanged !== undefined && ( 0; + const showValidationSummary = hasPositiveValidationResultCount( + nodeRuns?.validation_summary, + ); + const hasVisibleRunsAggregatedData = + schemaChanged !== undefined || + nodeRuns?.row_count_diff !== undefined || + showValidationSummary; const runsAggregatedTag = - selectMode !== "action_result" && data.resourceType === "model" ? ( + selectMode !== "action_result" && + data.resourceType === "model" && + hasVisibleRunsAggregatedData ? ( openNodeDetails(nodeId, "analysis")} + runs={nodeRuns} + schemaChanged={schemaChanged} + showValidationSummary={showValidationSummary} /> ) : undefined; diff --git a/js/packages/ui/src/components/lineage/LineageViewOss.tsx b/js/packages/ui/src/components/lineage/LineageViewOss.tsx index 4aa96bdd3..def372b7a 100644 --- a/js/packages/ui/src/components/lineage/LineageViewOss.tsx +++ b/js/packages/ui/src/components/lineage/LineageViewOss.tsx @@ -26,6 +26,8 @@ import { type LineageGraphNode, type LineageGraphNodes, type LineageViewContextType, + type NodeDetailsOpenRequest, + type NodeDetailsView, } from "../../contexts/lineage/types"; import { type NodeColumnSetMap, @@ -144,6 +146,14 @@ export function nextFocusedNodeId( return currentNodeId === clickedNodeId ? undefined : clickedNodeId; } +export function createNodeDetailsOpenRequest( + currentToken: number, + nodeId: string, + view: NodeDetailsView, +): NodeDetailsOpenRequest { + return { nodeId, view, requestToken: currentToken + 1 }; +} + /** * Compute impacted node IDs and column IDs in a single pass over the CLL data. * @@ -374,6 +384,9 @@ export function PrivateLineageView( * Focused node: the node that is currently focused. Show the NodeView when a node is focused */ const [focusedNodeId, setFocusedNodeId] = useState(); + const [nodeDetailsOpenRequest, setNodeDetailsOpenRequest] = + useState(); + const nodeDetailsRequestTokenRef = useRef(0); const focusedNode = focusedNodeId ? lineageGraph?.nodes[focusedNodeId] : undefined; @@ -732,8 +745,28 @@ export function PrivateLineageView( const onNodeViewClosed = () => { supersedeCllInteraction(); setFocusedNodeId(undefined); + setNodeDetailsOpenRequest(undefined); + setFocusedHistory([]); + }; + + const openNodeDetails = (nodeId: string, view: NodeDetailsView) => { + if (!lineageGraph?.nodes[nodeId]) return; + supersedeCllInteraction(); + const request = createNodeDetailsOpenRequest( + nodeDetailsRequestTokenRef.current, + nodeId, + view, + ); + nodeDetailsRequestTokenRef.current = request.requestToken; + setNodeDetailsOpenRequest(request); + setFocusedNodeId(nodeId); setFocusedHistory([]); }; + const consumeNodeDetailsOpenRequest = useCallback((requestToken: number) => { + setNodeDetailsOpenRequest((current) => + current?.requestToken === requestToken ? undefined : current, + ); + }, []); /** * Navigate the Model Detail panel to a different node without touching the @@ -744,6 +777,7 @@ export function PrivateLineageView( const navigateToNode = (nodeId: string) => { if (!lineageGraph?.nodes[nodeId]) return; supersedeCllInteraction(); + setNodeDetailsOpenRequest(undefined); if (focusedNodeId === nodeId) return; if (focusedNodeId && focusedNodeId !== nodeId) { setFocusedHistory((h) => [...h, focusedNodeId]); @@ -756,6 +790,7 @@ export function PrivateLineageView( const previous = focusedHistory[focusedHistory.length - 1]; if (!lineageGraph?.nodes[previous]) return; supersedeCllInteraction(); + setNodeDetailsOpenRequest(undefined); setFocusedNodeId(previous); setFocusedHistory((h) => h.slice(0, -1)); }; @@ -769,6 +804,7 @@ export function PrivateLineageView( const target = focusedHistory[index]; if (!lineageGraph?.nodes[target]) return; supersedeCllInteraction(); + setNodeDetailsOpenRequest(undefined); setFocusedNodeId(target); setFocusedHistory((h) => h.slice(0, index)); }; @@ -912,6 +948,7 @@ export function PrivateLineageView( closeContextMenu(); supersedeCllInteraction(); + setNodeDetailsOpenRequest(undefined); if (!selectMode) { setFocusedNodeId(nextFocusedNodeId(focusedNodeId, node.id)); setFocusedHistory([]); @@ -1402,6 +1439,7 @@ export function PrivateLineageView( onViewOptionsChanged: handleViewOptionsChanged, selectMode, selectNode, + openNodeDetails, selectParentNodes, selectChildNodes, deselect, @@ -1767,6 +1805,8 @@ export function PrivateLineageView( 0 ? navigateBack : undefined} onCenterFocused={centerFocusedNode} diff --git a/js/packages/ui/src/components/lineage/NodeView.tsx b/js/packages/ui/src/components/lineage/NodeView.tsx index 847893ff7..baaa1788e 100644 --- a/js/packages/ui/src/components/lineage/NodeView.tsx +++ b/js/packages/ui/src/components/lineage/NodeView.tsx @@ -13,11 +13,14 @@ import { type ComponentType, type ReactElement, type ReactNode, + useEffect, + useRef, useState, } from "react"; import { IoClose } from "react-icons/io5"; import type { NodeData } from "../../api/info"; import { DisableTooltipMessages } from "../../constants"; +import type { NodeDetailsOpenRequest } from "../../contexts/lineage/types"; import { useThemeColors } from "../../hooks"; import { formatTimeToNow } from "../../utils"; import { getChangeCategoryLabel } from "./changeCategory"; @@ -210,6 +213,10 @@ export interface NodeViewProps< recentAnalysisRuns?: RecentAnalysisRun[]; /** Reopen a prior analysis result in the consumer's result pane. */ onViewAnalysisRun?: (runId: string) => void; + /** Explicit, repeatable request to select a detail-panel view. */ + openRequest?: NodeDetailsOpenRequest; + /** Acknowledge that an explicit detail-panel request was applied. */ + onOpenRequestConsumed?: (requestToken: number) => void; // ========================================================================= // DEPENDENCY INJECTION: Icons @@ -680,6 +687,8 @@ export function NodeView({ rowCountDisplay, recentAnalysisRuns = [], onViewAnalysisRun, + openRequest, + onOpenRequestConsumed, }: NodeViewProps) { const withColumns = node.data.resourceType === "model" || @@ -714,6 +723,25 @@ export function NodeView({ node.data.resourceType === "seed" || node.data.resourceType === "snapshot"; const showAnalysisTab = !isSingleEnv && isModelSeedOrSnapshot; + const appliedRequestTokenRef = useRef(undefined); + + useEffect(() => { + if ( + openRequest === undefined || + appliedRequestTokenRef.current === openRequest.requestToken + ) { + return; + } + if ( + openRequest.nodeId === node.id && + openRequest.view === "analysis" && + showAnalysisTab + ) { + appliedRequestTokenRef.current = openRequest.requestToken; + setTabState({ nodeId: node.id, value: "analysis" }); + onOpenRequestConsumed?.(openRequest.requestToken); + } + }, [node.id, onOpenRequestConsumed, openRequest, showAnalysisTab]); const showAddSchemaDiff = !isSingleEnv && diff --git a/js/packages/ui/src/components/lineage/NodeViewOss.tsx b/js/packages/ui/src/components/lineage/NodeViewOss.tsx index 85aa4511b..2b99b10e7 100644 --- a/js/packages/ui/src/components/lineage/NodeViewOss.tsx +++ b/js/packages/ui/src/components/lineage/NodeViewOss.tsx @@ -31,6 +31,7 @@ import { useRecceInstanceContext, useRouteConfig, } from "../../contexts"; +import type { NodeDetailsOpenRequest } from "../../contexts/lineage/types"; import { useApiConfig, useModelColumns, @@ -67,6 +68,10 @@ import { pickWholeModelFlags } from "./wholeModelTreatment"; interface NodeViewProps { node: LineageGraphNode; onCloseNode: () => void; + /** Explicit, repeatable request to select a detail-panel view. */ + openRequest?: NodeDetailsOpenRequest; + /** Acknowledge that an explicit detail-panel request was applied. */ + onOpenRequestConsumed?: (requestToken: number) => void; /** Navigate to another node: refocus the panel (canvas is not re-centered). */ onNavigateToNode?: (nodeId: string) => void; /** Return to the previously focused node. Omit to hide the back button. */ @@ -137,6 +142,8 @@ function OssNotificationComponent({ onClose }: { onClose: () => void }) { export function NodeViewOss({ node, onCloseNode, + openRequest, + onOpenRequestConsumed, onNavigateToNode, onBack, onCenterFocused, @@ -399,6 +406,8 @@ export function NodeViewOss({ , +): LineageViewContextType { + return { + interactive: true, + nodes: [], + selectedNodes: [], + cll: undefined, + showContextMenu: vi.fn(), + viewOptions: {}, + onViewOptionsChanged: vi.fn(), + selectMode, + selectNode: vi.fn(), + openNodeDetails, + getNodeAction: vi.fn(), + getNodeColumnSet: vi.fn(() => new Set()), + isNodeHighlighted: vi.fn(() => false), + isNodeSelected: vi.fn(() => false), + isNodeShowingChangeAnalysis: vi.fn(() => false), + newCllExperience: false, + impactedNodeIds: new Set(), + impactedColumnIds: new Set(), + wholeModelChangedNodeIds: new Set(), + wholeModelImpactedNodeIds: new Set(), + } as unknown as LineageViewContextType; +} + +function renderInReactFlow(selectMode: SelectMode = undefined) { + const node = createNode(); + const onNodeClick = vi.fn(); + const onNodeDoubleClick = vi.fn(); + const onNodeContextMenu = vi.fn(); + const onNodeDragStart = vi.fn(); + const onNodesChange = vi.fn(); + const openNodeDetails = vi.fn(); + const runsAggregated: NodeRunsAggregated = { + validation_summary: { + result_count: 1, + difference_count: 0, + types: { + profile_diff: { + result_available: true, + latest_run_id: "profile-1", + result_count: 1, + }, + }, + }, + }; + + render( + + +
+ +
+
+
, + ); + + return { + onNodeClick, + onNodeDoubleClick, + onNodeContextMenu, + onNodeDragStart, + onNodesChange, + openNodeDetails, + }; +} + +describe("GraphNode React Flow interaction isolation", () => { + it("uses React Flow interaction guards while preserving native Enter and Space activation", async () => { + const user = userEvent.setup(); + const callbacks = renderInReactFlow(); + const trigger = screen.getByRole("button", { + name: /orders.*1 result/i, + }); + + expect(trigger).toHaveClass("nodrag", "nopan", "nokey"); + callbacks.onNodesChange.mockClear(); + + trigger.focus(); + await user.keyboard("{Enter} "); + + expect(callbacks.openNodeDetails).toHaveBeenCalledTimes(2); + expect(callbacks.onNodeClick).not.toHaveBeenCalled(); + expect(callbacks.onNodesChange).not.toHaveBeenCalled(); + + fireEvent.mouseDown(trigger, { button: 0, clientX: 10, clientY: 10 }); + fireEvent.mouseMove(document, { buttons: 1, clientX: 50, clientY: 50 }); + fireEvent.mouseUp(document, { button: 0, clientX: 50, clientY: 50 }); + + expect(callbacks.onNodeDragStart).not.toHaveBeenCalled(); + expect(callbacks.onNodesChange).not.toHaveBeenCalled(); + }); + + it.each([ + ["normal", undefined], + ["selecting", "selecting" as const], + ])( + "contains portaled detail-surface events in %s mode", + (_mode, selectMode) => { + const callbacks = renderInReactFlow(selectMode); + const trigger = screen.getByRole("button", { + name: /orders.*1 result/i, + }); + callbacks.onNodesChange.mockClear(); + + fireEvent.mouseEnter(trigger); + const dialog = screen.getByRole("dialog", { + name: "orders validation details", + }); + + fireEvent.pointerDown(dialog); + fireEvent.mouseDown(dialog, { button: 0 }); + fireEvent.click(dialog); + fireEvent.doubleClick(dialog); + const contextMenuEvent = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + fireEvent(dialog, contextMenuEvent); + + expect(callbacks.onNodeClick).not.toHaveBeenCalled(); + expect(callbacks.onNodeDoubleClick).not.toHaveBeenCalled(); + expect(callbacks.onNodeContextMenu).not.toHaveBeenCalled(); + expect(callbacks.onNodeDragStart).not.toHaveBeenCalled(); + expect(callbacks.onNodesChange).not.toHaveBeenCalled(); + expect(contextMenuEvent.defaultPrevented).toBe(false); + expect(dialog).toBeInTheDocument(); + }, + ); +}); diff --git a/js/packages/ui/src/components/lineage/__tests__/GraphNodeOss.test.tsx b/js/packages/ui/src/components/lineage/__tests__/GraphNodeOss.test.tsx index f04c4dfda..a50a4d669 100644 --- a/js/packages/ui/src/components/lineage/__tests__/GraphNodeOss.test.tsx +++ b/js/packages/ui/src/components/lineage/__tests__/GraphNodeOss.test.tsx @@ -1,14 +1,19 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { vi } from "vitest"; -const { mockGraphContext, mockLineageNode, mockViewContext } = vi.hoisted( - () => ({ - mockGraphContext: { current: undefined as unknown }, - mockLineageNode: vi.fn(), - mockViewContext: { current: undefined as unknown }, - }), -); +const { + mockGraphContext, + mockLineageNode, + mockNodeSurfaceClick, + mockViewContext, +} = vi.hoisted(() => ({ + mockGraphContext: { current: undefined as unknown }, + mockLineageNode: vi.fn(), + mockNodeSurfaceClick: vi.fn(), + mockViewContext: { current: undefined as unknown }, +})); vi.mock("@xyflow/react", async (importOriginal) => { const actual = await importOriginal(); @@ -42,7 +47,11 @@ vi.mock("../nodes", async (importOriginal) => { selectMode?: string; }) => { mockLineageNode(props); - return
{props.runsAggregatedTag}
; + return ( +
+ {props.runsAggregatedTag} +
+ ); }, }; }); @@ -55,10 +64,13 @@ import { getSemanticColorTheme } from "../../../theme"; import { GraphNode, type GraphNodeProps } from "../GraphNodeOss"; function createViewContext( - overrides: Partial = {}, + overrides: Partial & { + openNodeDetails?: (nodeId: string, view: "analysis") => void; + } = {}, ): LineageViewContextType { return { interactive: true, + openNodeDetails: vi.fn(), selectNode: vi.fn(), selectMode: "normal", focusedNode: undefined, @@ -80,14 +92,15 @@ function createViewContext( } as LineageViewContextType; } -function createNodeProps(): GraphNodeProps { +function createNodeProps(id = "model.test.orders"): GraphNodeProps { + const name = id.split(".").at(-1) ?? id; const node: LineageGraphNode = { - id: "model.test.orders", + id, type: "lineageGraphNode", position: { x: 0, y: 0 }, data: { - id: "model.test.orders", - name: "orders", + id, + name, resourceType: "model", changeStatus: "modified", change: { category: "non_breaking" }, // wire-enum-ok @@ -110,6 +123,24 @@ function createNodeProps(): GraphNodeProps { }; } +type ValidationTypes = NonNullable< + NonNullable< + import("../../../api").NodeRunsAggregated["validation_summary"] + >["types"] +>; + +function validationSummary( + resultCount: number, + differenceCount: number, + types: ValidationTypes, +) { + return { + result_count: resultCount, + difference_count: differenceCount, + types, + }; +} + describe("GraphNode", () => { beforeEach(() => { vi.clearAllMocks(); @@ -198,6 +229,439 @@ describe("GraphNode", () => { expect(selectNode).toHaveBeenCalledWith("model.test.orders"); }); + it("does not mount the aggregate display when the node has no visible schema, row-count, or validation data", () => { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + row_count: { run_id: "row-count", result: { curr: 42 } }, + }, + }, + }; + + render(); + + expect(mockLineageNode).toHaveBeenLastCalledWith( + expect.objectContaining({ runsAggregatedTag: undefined }), + ); + expect( + screen.queryByTestId("node-runs-aggregated-display"), + ).not.toBeInTheDocument(); + }); + + it.each([ + ["zero", 0], + ["negative", -1], + ["NaN", Number.NaN], + ["infinite", Number.POSITIVE_INFINITY], + ])( + "does not mount an otherwise-empty aggregate display for a %s validation result count", + (_label, resultCount) => { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + validation_summary: validationSummary(resultCount, 0, {}), + }, + }, + }; + + render(); + + expect(mockLineageNode).toHaveBeenLastCalledWith( + expect.objectContaining({ runsAggregatedTag: undefined }), + ); + expect( + screen.queryByTestId("node-runs-aggregated-display"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("validation-summary-chip")).toBeNull(); + }, + ); + + it("preserves schema and row-count evidence while hiding a zero-result validation summary", () => { + const props = createNodeProps(); + props.data.change = { + category: "non_breaking", // wire-enum-ok + columns: { id: "modified" }, + }; + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + row_count_diff: { + run_id: "row-count-1", + result: { base: 100, curr: 150 }, + }, + validation_summary: validationSummary(0, 0, {}), + }, + }, + }; + + render(); + + expect( + screen.getByTestId("node-runs-aggregated-display"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Schema (changed)")).toBeInTheDocument(); + expect(screen.getByText("↑ +50.0% Rows")).toBeInTheDocument(); + expect(screen.queryByTestId("validation-summary-chip")).toBeNull(); + }); + + it.each([ + { + name: "value diff", + summary: validationSummary(1, 1, { + value_diff: { + result_available: true, + latest_run_id: "value-1", + difference_count: 1, + }, + }), + chip: "1 result · 1 diff", + detail: "1 result · 1 diff", + }, + { + name: "profile diff", + summary: validationSummary(1, 91, { + profile_diff: { + result_available: true, + latest_run_id: "profile-1", + result_count: 1, + }, + }), + chip: "1 result", + detail: "1 result", + }, + { + name: "top-k diff", + summary: validationSummary(2, 91, { + top_k_diff: { + result_available: true, + latest_run_ids_by_column: { country: "top-k-1", status: "top-k-2" }, + column_count: 2, + }, + }), + chip: "2 results", + detail: "2 columns", + }, + { + name: "histogram diff", + summary: validationSummary(3, 91, { + histogram_diff: { + result_available: true, + latest_run_ids_by_column: { + amount: "histogram-1", + quantity: "histogram-2", + tax: "histogram-3", + }, + column_count: 3, + }, + }), + chip: "3 results", + detail: "3 columns", + }, + ])( + "renders neutral, type-correct counts for a $name summary", + ({ name, summary, chip, detail }) => { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { validation_summary: summary }, + }, + }; + + render(); + + const trigger = screen.getByRole("button", { + name: new RegExp(`orders.*${chip.replace(" · ", ".*")}`, "i"), + }); + expect(trigger).toHaveTextContent(chip); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveStyle({ minHeight: "24px" }); + expect(trigger.querySelectorAll(".MuiChip-root")).toHaveLength(1); + expect(trigger.querySelector(".MuiChip-root")).toHaveClass( + "MuiChip-colorDefault", + "MuiChip-outlined", + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + fireEvent.mouseEnter(trigger); + + const dialog = screen.getByRole("dialog", { + name: "orders validation details", + }); + expect(dialog).toBeInTheDocument(); + expect( + within(dialog).getByText(new RegExp(name, "i")), + ).toBeInTheDocument(); + expect( + within(dialog).getByText(detail, { exact: true }), + ).toBeInTheDocument(); + }, + ); + + it("renders one mixed-summary chip and readable per-type details", () => { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + validation_summary: validationSummary(7, 2, { + value_diff: { + result_available: true, + latest_run_id: "value-1", + difference_count: 2, + }, + profile_diff: { + result_available: true, + latest_run_id: "profile-1", + result_count: 1, + }, + top_k_diff: { + result_available: true, + latest_run_ids_by_column: { + country: "top-k-1", + status: "top-k-2", + }, + column_count: 2, + }, + histogram_diff: { + result_available: true, + latest_run_ids_by_column: { + amount: "histogram-1", + quantity: "histogram-2", + tax: "histogram-3", + }, + column_count: 3, + }, + }), + }, + }, + }; + + render(); + + const trigger = screen.getByRole("button", { + name: /orders.*7 results.*2 diffs/i, + }); + expect(trigger).toHaveTextContent("7 results · 2 diffs"); + expect(screen.getAllByTestId("validation-summary-chip")).toHaveLength(1); + + fireEvent.mouseEnter(trigger); + + expect(screen.getByText("Value diff")).toBeInTheDocument(); + expect(screen.getByText("Profile diff")).toBeInTheDocument(); + expect(screen.getByText("Top-k diff")).toBeInTheDocument(); + expect(screen.getByText("Histogram diff")).toBeInTheDocument(); + expect(screen.getByText("1 result · 2 diffs")).toBeInTheDocument(); + expect(screen.getByText("1 result", { exact: true })).toBeInTheDocument(); + expect(screen.getAllByText("2 columns")).toHaveLength(1); + expect(screen.getAllByText("3 columns")).toHaveLength(1); + }); + + it("opens Analysis repeatably from keyboard without toggling batch selection or the node surface", async () => { + const user = userEvent.setup(); + const openNodeDetails = vi.fn(); + const selectNode = vi.fn(); + mockViewContext.current = createViewContext({ + openNodeDetails, + selectNode, + }); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + validation_summary: validationSummary(1, 0, { + value_diff: { + result_available: true, + latest_run_id: "value-1", + difference_count: 0, + }, + }), + }, + }, + }; + + render(); + + const trigger = screen.getByRole("button", { + name: /orders.*1 result.*0 diffs/i, + }); + await user.tab(); + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + await user.keyboard("{Enter} "); + + expect(openNodeDetails).toHaveBeenNthCalledWith( + 1, + "model.test.orders", + "analysis", + ); + expect(openNodeDetails).toHaveBeenNthCalledWith( + 2, + "model.test.orders", + "analysis", + ); + expect(selectNode).not.toHaveBeenCalled(); + expect(mockNodeSurfaceClick).not.toHaveBeenCalled(); + }); + + it("keeps lazy details open while the summary button retains focus", () => { + vi.useFakeTimers(); + try { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + validation_summary: validationSummary(1, 0, { + profile_diff: { + result_available: true, + latest_run_id: "profile-1", + result_count: 1, + }, + }), + }, + }, + }; + render(); + const trigger = screen.getByRole("button", { name: /orders.*1 result/i }); + + act(() => trigger.focus()); + fireEvent.mouseLeave(trigger); + act(() => vi.advanceTimersByTime(101)); + + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("adds and removes the display as aggregate data changes", () => { + mockViewContext.current = createViewContext(); + mockGraphContext.current = { runsAggregated: {} }; + const initialProps = createNodeProps(); + const { rerender } = render(); + expect( + screen.queryByTestId("node-runs-aggregated-display"), + ).not.toBeInTheDocument(); + + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + validation_summary: validationSummary(1, 0, { + profile_diff: { + result_available: true, + latest_run_id: "profile-1", + result_count: 1, + }, + }), + }, + }, + }; + rerender(); + expect(screen.getByText("1 result")).toBeInTheDocument(); + + mockGraphContext.current = { runsAggregated: {} }; + rerender(); + expect( + screen.queryByTestId("node-runs-aggregated-display"), + ).not.toBeInTheDocument(); + }); + + it("coexists with schema, row-count, and new-CLL presentation inside the fixed node slot", () => { + const props = createNodeProps(); + props.data.change = { + category: "partial_breaking", // wire-enum-ok + columns: {}, + }; + mockViewContext.current = createViewContext({ newCllExperience: true }); + mockGraphContext.current = { + runsAggregated: { + "model.test.orders": { + row_count_diff: { + run_id: "row-count-1", + result: { base: 100, curr: 150 }, + }, + validation_summary: validationSummary(1234567, 765432, { + value_diff: { + result_available: true, + latest_run_id: "value-1", + difference_count: 765432, + }, + }), + }, + }, + }; + + render(); + + expect(screen.getByText("↑ +50.0% Rows")).toBeInTheDocument(); + expect( + screen.getByText("1,234,567 results · 765,432 diffs"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Schema (no change)")).toBeInTheDocument(); + expect(mockLineageNode).toHaveBeenLastCalledWith( + expect.objectContaining({ + changeCategory: undefined, + showChangeAnalysis: false, + }), + ); + }); + + it("mounts aggregate displays and popovers in proportion to summarized nodes in a large sparse graph", () => { + const summarizedNodeIds = [ + "model.test.node_7", + "model.test.node_503", + "model.test.node_997", + ]; + mockViewContext.current = createViewContext(); + mockGraphContext.current = { + runsAggregated: Object.fromEntries( + summarizedNodeIds.map((id, index) => [ + id, + { + validation_summary: validationSummary(index + 1, 0, { + profile_diff: { + result_available: true, + latest_run_id: `profile-${index}`, + result_count: 1, + }, + }), + }, + ]), + ), + }; + + render( + <> + {Array.from({ length: 1000 }, (_, index) => { + const id = `model.test.node_${index}`; + return ; + })} + , + ); + + expect(screen.getAllByTestId("node-runs-aggregated-display")).toHaveLength( + summarizedNodeIds.length, + ); + expect(screen.getAllByTestId("validation-summary-chip")).toHaveLength( + summarizedNodeIds.length, + ); + expect(screen.queryAllByRole("dialog")).toHaveLength(0); + + fireEvent.mouseEnter( + screen.getByRole("button", { name: /node_503.*2 results/i }), + ); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + }); + it.each([ [100, 150, "↑ +50.0% Rows", "increase"], [100, 80, "↓ -20.0% Rows", "decrease"], diff --git a/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx b/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx index a008781b1..27f211b09 100644 --- a/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx +++ b/js/packages/ui/src/components/lineage/__tests__/LineageNode.test.tsx @@ -584,6 +584,32 @@ describe("LineageNode", () => { expect(screen.getByTestId("runs-tag")).toBeInTheDocument(); }); + it("keeps the change label and aggregate evidence in the same fixed 300x60 card", () => { + const props = createMockNodeProps( + { + showChangeAnalysis: true, + changeCategory: "non_breaking", // wire-enum-ok + runsAggregatedTag: ( + + 1,234,567 results · 765,432 diffs + + ), + }, + { label: "coexistence" }, + ); + + render(); + + expect(screen.getByText("Additive Change")).toBeInTheDocument(); + expect(screen.getByTestId("runs-tag")).toBeInTheDocument(); + expect(screen.getByTestId("lineage-node-card")).toHaveStyle({ + height: "60px", + }); + expect(screen.getByTestId("lineage-node-card").parentElement).toHaveStyle( + { width: "300px" }, + ); + }); + it("does not render runsAggregatedTag in action_result mode", () => { const props = createMockNodeProps( { @@ -597,6 +623,23 @@ describe("LineageNode", () => { expect(screen.queryByTestId("runs-tag")).not.toBeInTheDocument(); }); + + it("keeps the change label in action_result mode when the node has no action tag", () => { + const props = createMockNodeProps( + { + selectMode: "action_result", + showChangeAnalysis: true, + changeCategory: "non_breaking", // wire-enum-ok + runsAggregatedTag: +10%, + }, + { label: "test" }, + ); + + render(); + + expect(screen.getByText("Additive Change")).toBeInTheDocument(); + expect(screen.queryByTestId("runs-tag")).not.toBeInTheDocument(); + }); }); // ========================================================================== diff --git a/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts b/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts index 0dffcd4ad..3cc581f7d 100644 --- a/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts +++ b/js/packages/ui/src/components/lineage/__tests__/LineageViewFocus.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { nextFocusedNodeId } from "../LineageViewOss"; +import { + createNodeDetailsOpenRequest, + nextFocusedNodeId, +} from "../LineageViewOss"; describe("nextFocusedNodeId", () => { it("focuses a newly clicked node", () => { @@ -20,3 +23,22 @@ describe("nextFocusedNodeId", () => { ); }); }); + +describe("createNodeDetailsOpenRequest", () => { + it("issues a new token for repeated requests to the same node and view", () => { + expect( + createNodeDetailsOpenRequest(40, "model.test.orders", "analysis"), + ).toEqual({ + nodeId: "model.test.orders", + view: "analysis", + requestToken: 41, + }); + expect( + createNodeDetailsOpenRequest(41, "model.test.orders", "analysis"), + ).toEqual({ + nodeId: "model.test.orders", + view: "analysis", + requestToken: 42, + }); + }); +}); diff --git a/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx b/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx index 2976d070c..498750107 100644 --- a/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx +++ b/js/packages/ui/src/components/lineage/__tests__/NodeView.test.tsx @@ -9,8 +9,10 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useState } from "react"; import { afterEach, beforeEach, vi } from "vitest"; import type { NodeColumnData } from "../../../api"; +import type { NodeDetailsOpenRequest } from "../../../contexts"; import type { NodeViewNodeData } from "../NodeView"; import { NodeView } from "../NodeView"; @@ -259,6 +261,146 @@ describe("NodeView", () => { ); }); + test("honors repeated explicit Analysis requests for the same focused node", () => { + const node = createNode("model", testColumns); + const sharedProps = { + node, + modelDetail: createModelDetail(testColumns), + onCloseNode: vi.fn(), + isSingleEnv: false, + SchemaView: MockSchemaView, + }; + const { rerender } = render( + , + ); + + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.click(screen.getByRole("tab", { name: "Columns" })); + expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute( + "aria-selected", + "true", + ); + + rerender( + , + ); + + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + test("consumes an Analysis request so remounting cannot replay it while later activations still work", () => { + const node = createNode("model", testColumns); + + function RequestHarness() { + const [mounted, setMounted] = useState(true); + const [nextToken, setNextToken] = useState(1); + const [request, setRequest] = useState< + NodeDetailsOpenRequest | undefined + >({ nodeId: node.id, view: "analysis", requestToken: 1 }); + + return ( + <> + + + {mounted && ( + + setRequest((current) => + current?.requestToken === requestToken + ? undefined + : current, + ) + } + /> + )} + + ); + } + + render(); + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.click(screen.getByRole("tab", { name: "Columns" })); + fireEvent.click(screen.getByRole("button", { name: "Unmount details" })); + fireEvent.click(screen.getByRole("button", { name: "Remount details" })); + + expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute( + "aria-selected", + "true", + ); + + fireEvent.click(screen.getByRole("button", { name: "Request Analysis" })); + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.click(screen.getByRole("tab", { name: "Columns" })); + fireEvent.click(screen.getByRole("button", { name: "Request Analysis" })); + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + test("ignores an Analysis request addressed to a different node", () => { + const node = createNode("model", testColumns); + renderNodeView(node, testColumns, { + openRequest: { + nodeId: "model.test.somewhere_else", + view: "analysis", + requestToken: 1, + }, + }); + + expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + test.each(["Analysis", "Lineage"])( "resets to Columns when focus moves from the %s tab to a source node", (selectedTab) => { diff --git a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx index a5203bb1d..9800a427c 100644 --- a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx +++ b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx @@ -668,27 +668,43 @@ function LineageNodeComponent({ visibility: showContent ? "inherit" : "hidden", }} > - + {actionTag ? ( <> {actionTag} - ) : changeCategoryLabel ? ( - - {changeCategoryLabel} - - ) : selectMode !== "action_result" && runsAggregatedTag ? ( - runsAggregatedTag - ) : null} + ) : ( + <> + {changeCategoryLabel && ( + + {changeCategoryLabel} + + )} + {selectMode !== "action_result" && runsAggregatedTag && ( + + {runsAggregatedTag} + + )} + + )}
diff --git a/js/packages/ui/src/contexts-entry.ts b/js/packages/ui/src/contexts-entry.ts index 043da8a38..e01ff4b41 100644 --- a/js/packages/ui/src/contexts-entry.ts +++ b/js/packages/ui/src/contexts-entry.ts @@ -59,6 +59,8 @@ export type { LineageViewContextType, NodeAction, NodeColumnSetMap, + NodeDetailsOpenRequest, + NodeDetailsView, SelectMode, } from "./contexts/lineage"; export { diff --git a/js/packages/ui/src/contexts/index.ts b/js/packages/ui/src/contexts/index.ts index 35ea9737e..aef5581c9 100644 --- a/js/packages/ui/src/contexts/index.ts +++ b/js/packages/ui/src/contexts/index.ts @@ -65,6 +65,8 @@ export type { LineageViewContextType, NodeAction, NodeColumnSetMap, + NodeDetailsOpenRequest, + NodeDetailsView, SelectMode, } from "./lineage"; export { diff --git a/js/packages/ui/src/contexts/lineage/index.ts b/js/packages/ui/src/contexts/lineage/index.ts index 8807edd4f..3630e3666 100644 --- a/js/packages/ui/src/contexts/lineage/index.ts +++ b/js/packages/ui/src/contexts/lineage/index.ts @@ -28,6 +28,8 @@ export type { LineageGraphNodes, LineageViewContextType, NodeAction, + NodeDetailsOpenRequest, + NodeDetailsView, SelectMode, } from "./types"; diff --git a/js/packages/ui/src/contexts/lineage/types.ts b/js/packages/ui/src/contexts/lineage/types.ts index 6e078c489..0bbffe882 100644 --- a/js/packages/ui/src/contexts/lineage/types.ts +++ b/js/packages/ui/src/contexts/lineage/types.ts @@ -186,6 +186,20 @@ export type ActionMode = "per_node" | "multi_nodes"; */ export type SelectMode = "selecting" | "action_result" | undefined; +/** A detail-panel destination that a lineage-node affordance can request. */ +export type NodeDetailsView = "analysis"; + +/** + * An explicit request to open one node's detail panel at a specific view. + * `requestToken` changes for every activation, including repeated activation + * of the same node and view. + */ +export interface NodeDetailsOpenRequest { + nodeId: string; + view: NodeDetailsView; + requestToken: number; +} + /** * Action state for individual node operations */ @@ -249,6 +263,8 @@ export interface LineageViewContextType { selectMode: SelectMode; /** Select/deselect a single node */ selectNode: (nodeId: string) => void; + /** Open a node's detail panel without changing batch selection. */ + openNodeDetails: (nodeId: string, view: NodeDetailsView) => void; /** Select all parent nodes up to a degree */ selectParentNodes: (nodeId: string, degree?: number) => void; /** Select all child nodes up to a degree */ diff --git a/js/packages/ui/src/index.ts b/js/packages/ui/src/index.ts index f8e8d8985..623149ccf 100644 --- a/js/packages/ui/src/index.ts +++ b/js/packages/ui/src/index.ts @@ -124,6 +124,8 @@ export type { LineageViewContextType, NodeAction, NodeColumnSetMap, + NodeDetailsOpenRequest, + NodeDetailsView, RecceActionContextType, RecceActionOptions, RecceActionProviderProps, diff --git a/js/packages/ui/src/types/index.ts b/js/packages/ui/src/types/index.ts index 8df8093f1..6d821d5c0 100644 --- a/js/packages/ui/src/types/index.ts +++ b/js/packages/ui/src/types/index.ts @@ -149,6 +149,8 @@ export type { InstanceInfoType } from "../contexts/instance"; export type { LineageGraphContextType, LineageGraphProviderProps, + NodeDetailsOpenRequest, + NodeDetailsView, } from "../contexts/lineage"; // Lineage graph types export type {