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
8 changes: 6 additions & 2 deletions frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6387,9 +6387,13 @@ snapshots:
products-data-modeling-lineage-graph--full--light:
hash: v1.k794b7964.3d2a0a0a6ff2a27f3f8d682857757695b61970b3bfdc3f50e7508c86257aa4ad.S_I2CjkAMi3HiNyo014ZRrZHZbcDaVTQwF4718lZNws
products-data-modeling-lineage-graph--search-focus--dark:
hash: v1.k794b7964.b4d9053ce873f58fd7242899886a9732c0daaa37eefe75fdf02b157608030db0.ZZPDrU7QcCWBEqceYc4pO5Xh6UHGMhyDXx_fN8UpsCg
hash: v1.k794b7964.50fc53d09a95dbfa156c1bb0234cb4ae94f1edad3c531971a6d29fc6e644792b.CnYgYXb_RjjTDPN13751BGVZ03jgtmKYiwd6IWXzvoE
products-data-modeling-lineage-graph--search-focus--light:
hash: v1.k794b7964.b740c4a0f8c1a657d2d87e3a2ba94082ccf74c65fe5a7fc52c1fa7804e3b900a.d2mXFF2Jwsu9Hjf3JVkojpMjZbV99slRc3Ol5S4418I
hash: v1.k794b7964.3d11e7ae38507207bbe6cc4e4a2e836551851f548b320ba9e68434a7f28955e4.5xbCZPz04Wxs56vbST3OAyMzXApj7I5Mn-p1Iummm5s
products-data-modeling-lineage-graph--selector-focus--dark:
hash: v1.k794b7964.e44cc4197946a139484ad0c1c6accb027a2459a4479584b1dbb62113bd6d01c1.u4OtT961hGsah7VBvU4uOB6puqdbBHM04pryfC_KByQ
products-data-modeling-lineage-graph--selector-focus--light:
hash: v1.k794b7964.3d864c004ba10e4965d2dd21416e56c24ed0920a1947558f1637b4a4188c7f2d.Bq_kGJNOot-IwoTiPYugFocCo6kP5f8szxMmW38B4RY
products-data-modeling-lineage-graph--single-node--dark:
hash: v1.k794b7964.3a7800242792b8dd49d06a252b0ccf609de5be3bf5b077e28904ca2ef46b9150.3jN6spC1a4TJhjq6LOuaDGym92gQz9Xw2KUfy0Br-D8
products-data-modeling-lineage-graph--single-node--light:
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| 'style'
| 'role'
| 'aria-haspopup'
| 'aria-current'
| 'aria-pressed'
| 'aria-expanded'
| 'aria-controls'
Expand Down Expand Up @@ -143,7 +144,7 @@
/** Styled button. */
export const LemonButton: React.FunctionComponent<LemonButtonProps & React.RefAttributes<HTMLButtonElement>> =
React.forwardRef(
(

Check warning on line 147 in frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`<anonymous>` has cyclomatic complexity 50 (warn >10)
{
children,
active = false,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 80 additions & 13 deletions products/data_modeling/frontend/lineage/LineageGraph.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ const GRAPH_EDGES: DataModelingEdge[] = [
mockEdge('e5', '4', '6'),
]

// Pruning the graph rekeys `lineageGraphLogic`, so react-flow unmounts while ELK lays the cone
// out again. Read the canvas on every poll — a node captured before the relayout is detached,
// and a detached element reports a zero-sized rect that passes any centering check.
const VIEWPORT_SETTLE_MS = 10000

async function expectNodeCentered(canvasElement: HTMLElement, nodeId: string, message: string): Promise<void> {
await waitFor(
() => {
const graph = canvasElement.querySelector<HTMLElement>('.react-flow')
const node = graph?.querySelector<HTMLElement>(`.react-flow__node[data-id="${nodeId}"]`)
if (!graph || !node) {
throw new Error(message)
}
const nodeBounds = node.getBoundingClientRect()
const graphBounds = graph.getBoundingClientRect()
if (
Math.abs(nodeBounds.x + nodeBounds.width / 2 - graphBounds.x - graphBounds.width / 2) > 5 ||
Math.abs(nodeBounds.y + nodeBounds.height / 2 - graphBounds.y - graphBounds.height / 2) > 5
) {
throw new Error(message)
}
},
{ timeout: VIEWPORT_SETTLE_MS }
)
}

type Story = StoryObj<typeof LineageGraph>
const meta: Meta<typeof LineageGraph> = {
title: 'Products/Data modeling/Lineage graph',
Expand Down Expand Up @@ -118,23 +144,64 @@ export const SearchFocus: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await canvas.findByText('monthly_report')
const graph = canvasElement.querySelector<HTMLElement>('.react-flow')!
const target = graph.querySelector<HTMLElement>('.react-flow__node[data-id="4"]')!
const search = canvas.getByPlaceholderText('Search, or +name for upstream')
fireEvent.change(search, { target: { value: 'monthly_report' } })
await waitFor(() => {
const nodeBounds = target.getBoundingClientRect()
const graphBounds = graph.getBoundingClientRect()
if (
Math.abs(nodeBounds.x + nodeBounds.width / 2 - graphBounds.x - graphBounds.width / 2) > 5 ||
Math.abs(nodeBounds.y + nodeBounds.height / 2 - graphBounds.y - graphBounds.height / 2) > 5
) {
throw new Error('The search match must be centered in the lineage viewport')
}
})
fireEvent.change(search, { target: { value: 'monthly' } })
await canvas.findByText('2 results')
fireEvent.keyDown(search, { key: 'ArrowDown' })
// The arrow key moves the selection through a kea listener, so the new result is only
// selected on the next tick. Pressing Enter in the same tick would focus the old one.
await canvas.findByText('monthly_recurring_revenue, result 2 of 2')
fireEvent.keyDown(search, { key: 'Enter' })
await expectNodeCentered(canvasElement, '6', 'The search match must be centered in the lineage viewport')
const graph = canvasElement.querySelector<HTMLElement>('.react-flow')!
if (graph.querySelectorAll('.react-flow__node').length !== GRAPH_NODES.length) {
throw new Error('Plain search must keep the rest of the graph visible')
}
const previousResult = canvas.getByLabelText('Previous result')
previousResult.focus()
fireEvent.click(previousResult)
if (document.activeElement !== search) {
throw new Error('Cycling results must return focus to the search input')
}
},
}

export const SelectorFocus: Story = {
render: () => <ModelsLineageTab />,
decorators: [
mswDecorator({
get: {
'/api/environments/:team_id/data_modeling_nodes/': { count: GRAPH_NODES.length, results: GRAPH_NODES },
'/api/environments/:team_id/data_modeling_edges/': { count: GRAPH_EDGES.length, results: GRAPH_EDGES },
},
}),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await canvas.findByText('revenue_summary')
const search = canvas.getByPlaceholderText('Search, or +name for upstream')
fireEvent.change(search, { target: { value: 'revenue_summary+' } })
await canvas.findByText('4 models · downstream')
fireEvent.keyDown(search, { key: 'ArrowDown' })
await canvas.findByText('monthly_report, result 2 of 4')
fireEvent.keyDown(search, { key: 'Enter' })
if ((search as HTMLInputElement).selectionStart !== 'revenue_summary'.length) {
throw new Error('A downstream selector must keep the caret before its trailing plus')
}
await waitFor(
() => {
const graph = canvasElement.querySelector<HTMLElement>('.react-flow')
if (!graph || graph.querySelectorAll('.react-flow__node').length !== 4) {
throw new Error('A downstream selector must keep only its lineage cone visible')
}
},
{ timeout: VIEWPORT_SETTLE_MS }
)
await expectNodeCentered(
canvasElement,
'4',
'The selected downstream model must be centered in the lineage viewport'
)
},
}

Expand Down
19 changes: 15 additions & 4 deletions products/data_modeling/frontend/lineage/LineageGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
/** Enable zoom/pan. Off by default for inline previews */
interactive?: boolean
fitViewOptions?: FitViewOptions
focusNodeIds?: Set<string>
focusNodeIds?: Set<string> | null
searchFocusRequest?: { nodeId: string; requestId: number } | null
showMinimap?: boolean
minimapPosition?: PanelPosition
showControls?: boolean
Expand All @@ -55,10 +56,10 @@
panelPosition?: PanelPosition
}

function LineageGraphContent(props: LineageGraphProps): JSX.Element {

Check warning on line 59 in products/data_modeling/frontend/lineage/LineageGraph.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`LineageGraphContent` has cyclomatic complexity 14 (warn >10)
const { fitView, viewportInitialized } = useReactFlow()
const { isDarkModeOn } = useValues(themeLogic)
const { currentNodeId, nodeState, nodeCallbacks, onNodeClick, focusNodeIds } = props
const { currentNodeId, nodeState, nodeCallbacks, onNodeClick, focusNodeIds, searchFocusRequest } = props
const { layout } = useValues(
lineageGraphLogic({
nodes: props.nodes,
Expand All @@ -72,8 +73,8 @@
if (!viewportInitialized || !focusNodeIds || !layout) {
return
}
// Keep the match readable when a search term identifies one or a few nodes. When the
// search is cleared, fit the whole graph again instead of leaving the viewport stranded.
// An empty focusNodeIds means the search was cleared, so fit the whole graph again rather
// than leave the viewport where the last selector zoomed it.
const nodes = focusNodeIds.size > 0 ? layout.nodes.filter((node) => focusNodeIds.has(node.id)) : layout.nodes
if (nodes.length > 0) {
void fitView({
Expand All @@ -85,6 +86,16 @@
}
}, [fitView, viewportInitialized, focusNodeIds, layout])

useEffect(() => {
if (!viewportInitialized || !searchFocusRequest || !layout) {
return
}
const node = layout.nodes.find((layoutNode) => layoutNode.id === searchFocusRequest.nodeId)
if (node) {
void fitView({ nodes: [node], padding: 0.2, duration: 400, maxZoom: 2 })
}
}, [fitView, viewportInitialized, searchFocusRequest, layout])

if (!layout) {
return (
<div className="flex items-center justify-center w-full h-full">
Expand Down
19 changes: 15 additions & 4 deletions products/data_modeling/frontend/lineage/LineageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
isRunning?: boolean
/** Ringed when a search or type filter highlights this node */
isHighlighted?: boolean
isSelected?: boolean
}

export interface LineageNodeCallbacks {
Expand Down Expand Up @@ -189,7 +190,7 @@
)
}

export function LineageNode({ data }: { data: LineageNodeData }): JSX.Element {

Check warning on line 193 in products/data_modeling/frontend/lineage/LineageNode.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`LineageNode` has cyclomatic complexity 42 (warn >10)
const { node, variant, direction, state, callbacks } = data
const [isHovered, setIsHovered] = useState(false)

Expand Down Expand Up @@ -231,10 +232,20 @@
<div
className={clsx(
'relative rounded-lg border bg-bg-light cursor-pointer min-w-[180px]',
state.isRunning && 'border-warning ring-2 ring-warning/30 animate-pulse',
!state.isRunning && state.isHighlighted && 'border-link ring-2 ring-link/30',
!state.isRunning && !state.isHighlighted && !state.isCurrent && 'border-border',
node.lineage_issue && !state.isRunning && !state.isHighlighted && 'border-warning',
state.isRunning && 'animate-pulse',
state.isRunning && !state.isSelected && 'border-warning ring-2 ring-warning/30',
state.isSelected && 'border-link ring-4 ring-link/40',
!state.isRunning && !state.isSelected && state.isHighlighted && 'border-link ring-2 ring-link/30',
!state.isRunning &&
!state.isSelected &&
!state.isHighlighted &&
!state.isCurrent &&
'border-border',
node.lineage_issue &&
!state.isRunning &&
!state.isSelected &&
!state.isHighlighted &&
'border-warning',
state.isCurrent && 'border-2'
)}
// eslint-disable-next-line react/forbid-dom-props
Expand Down
117 changes: 117 additions & 0 deletions products/data_modeling/frontend/lineage/LineageSearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react'

import { IconChevronRight } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'

import { DataModelingNode } from '~/types'

import { LineageSearchMode } from './lineageSearch'
import { NodeTypeTag } from './NodeTypeTag'

const RENDERED_RESULT_LIMIT = 50

export interface LineageSearchResultsProps {
results: DataModelingNode[]
selectedResultId?: string
anchorResultId?: string
mode: LineageSearchMode
onSelect: (nodeId: string) => void
onPrevious: () => void
onNext: () => void
}

export function LineageSearchResults({
results,
selectedResultId,
anchorResultId,
mode,
onSelect,
onPrevious,
onNext,
}: LineageSearchResultsProps): JSX.Element {
const selectedOptionRef = useRef<HTMLDivElement>(null)
const selectedPosition = results.findIndex((node) => node.id === selectedResultId) + 1
const resultCountLabel =
mode === 'search'
? `${results.length} ${results.length === 1 ? 'result' : 'results'}`
: `${results.length} ${results.length === 1 ? 'model' : 'models'} · ${
mode === 'both' ? 'both directions' : mode
}`

// A one-letter term matches most of a large warehouse, and only about eight rows fit. Render a
// window that holds the selection instead of one button per match, so arrow navigation still
// reaches every result and the list never mounts thousands of buttons.
const windowStart = Math.min(
Math.max(0, selectedPosition - 1 - Math.floor(RENDERED_RESULT_LIMIT / 2)),
Math.max(0, results.length - RENDERED_RESULT_LIMIT)
)
const renderedResults = results.slice(windowStart, windowStart + RENDERED_RESULT_LIMIT)

useEffect(() => {
selectedOptionRef.current?.scrollIntoView({ block: 'nearest' })
}, [selectedResultId])

return (
<div className="absolute top-3 left-3 z-10 w-80 max-w-[calc(100%-1.5rem)] rounded border bg-bg-light shadow-lg">
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 text-xs text-secondary">
<span>{resultCountLabel}</span>
{results.length > 0 && (
<div className="flex items-center gap-1">
<span>
{selectedPosition} of {results.length}
</span>
<LemonButton
size="xsmall"
noPadding
icon={<IconChevronRight className="-rotate-90" />}
aria-label="Previous result"
tooltip="Previous result"
onClick={onPrevious}
data-attr="models-lineage-search-previous"
/>
<LemonButton
size="xsmall"
noPadding
icon={<IconChevronRight className="rotate-90" />}
aria-label="Next result"
tooltip="Next result"
onClick={onNext}
data-attr="models-lineage-search-next"
/>
</div>
)}
</div>
<div className="max-h-56 overflow-y-auto p-1">
{results.length === 0 ? (
<div className="px-2 py-3 text-sm text-secondary">No matching models</div>
Comment thread
sakce marked this conversation as resolved.
) : (
renderedResults.map((node) => (
<div key={node.id} ref={node.id === selectedResultId ? selectedOptionRef : undefined}>
<LemonButton
fullWidth
size="small"
type="tertiary"
active={node.id === selectedResultId}
aria-current={node.id === selectedResultId}
className="justify-start"
onClick={() => onSelect(node.id)}
data-attr="models-lineage-search-result"
>
<span className="flex min-w-0 items-center gap-2">
<NodeTypeTag type={node.type} />
<span className="truncate">{node.name}</span>
{node.id === anchorResultId && (
<span className="ml-auto shrink-0 text-xs text-secondary">Anchor</span>
)}
</span>
</LemonButton>
</div>
))
)}
</div>
<div className="border-t px-2 py-1.5 text-[11px] text-secondary">
↑↓ to select · Enter to center · Esc to clear
</div>
</div>
)
}
Loading
Loading