Skip to content

Commit

Permalink
feat(paths): add paths-v2 visualization (#28495)
Browse files Browse the repository at this point in the history
  • Loading branch information
thmsobrmlr authored and pauldambra committed Feb 25, 2025
1 parent 30e12c7 commit 3eb65a3
Show file tree
Hide file tree
Showing 12 changed files with 972 additions and 3 deletions.
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export const FEATURE_FLAGS = {
ONBOARDING_NEW_PLANS_STEP: 'onboarding-new-plans-step', // owner: @joshsny #team-growth
EXPERIMENTAL_DASHBOARD_ITEM_RENDERING: 'experimental-dashboard-item-rendering', // owner: @thmsobrmlr #team-product-analytics
RECORDINGS_AI_FILTER: 'recordings-ai-filter', // owner: @veryayskiy #team-replay
PATHS_V2: 'paths-v2', // owner: @thmsobrmlr #team-product-analytics
TREE_VIEW: 'tree-view', // owner: @mariusandra #team-devex
EXPERIMENTS_NEW_QUERY_RUNNER: 'experiments-new-query-runner', // owner: #team-experiments
RECORDINGS_AI_REGEX: 'recordings-ai-regex', // owner: @veryayskiy #team-replay
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/queries/nodes/InsightViz/InsightVizDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { FunnelStepsTable } from 'scenes/insights/views/Funnels/FunnelStepsTable
import { InsightsTable } from 'scenes/insights/views/InsightsTable/InsightsTable'
import { Paths } from 'scenes/paths/Paths'
import { PathCanvasLabel } from 'scenes/paths/PathsLabel'
import { PathsV2 } from 'scenes/paths-v2/PathsV2'
import { RetentionContainer } from 'scenes/retention/RetentionContainer'
import { TrendInsight } from 'scenes/trends/Trends'

Expand Down Expand Up @@ -57,7 +58,7 @@ export function InsightVizDisplay({
embedded: boolean
inSharedMode?: boolean
}): JSX.Element | null {
const { insightProps, canEditInsight } = useValues(insightLogic)
const { insightProps, canEditInsight, isUsingPathsV1, isUsingPathsV2 } = useValues(insightLogic)

const { activeView } = useValues(insightNavLogic(insightProps))

Expand Down Expand Up @@ -159,7 +160,7 @@ export function InsightVizDisplay({
/>
)
case InsightType.PATHS:
return <Paths />
return isUsingPathsV2 ? <PathsV2 /> : <Paths />
default:
return null
}
Expand Down Expand Up @@ -251,7 +252,7 @@ export function InsightVizDisplay({
</div>

<div className="flex items-center gap-2">
{isPaths && <PathCanvasLabel />}
{isPaths && isUsingPathsV1 && <PathCanvasLabel />}
{isFunnels && <FunnelCanvasLabel />}
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/scenes/insights/insightLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ export const insightLogic: LogicWrapper<insightLogicType> = kea<insightLogicType
Scene.ExperimentsSharedMetrics,
].includes(activeScene),
],
isUsingPathsV1: [(s) => [s.featureFlags], (featureFlags) => !featureFlags[FEATURE_FLAGS.PATHS_V2]],
isUsingPathsV2: [(s) => [s.featureFlags], (featureFlags) => featureFlags[FEATURE_FLAGS.PATHS_V2]],
}),
listeners(({ actions, values }) => ({
saveInsight: async ({ redirectToViewMode }) => {
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/scenes/paths-v2/PathNodeLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { IconEllipsis } from '@posthog/icons'
import { LemonButton, LemonMenu, Tooltip } from '@posthog/lemon-ui'
import { captureException } from '@sentry/react'
import { useActions, useValues } from 'kea'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { userLogic } from 'scenes/userLogic'

import { AvailableFeature, InsightLogicProps } from '~/types'

import { pathsDataLogic } from './pathsDataLogic'
import { pageUrl, PathNodeData } from './pathUtils'
import { NODE_LABEL_HEIGHT, NODE_LABEL_LEFT_OFFSET, NODE_LABEL_TOP_OFFSET, NODE_LABEL_WIDTH } from './renderPaths'

export type PathNodeLabelProps = {
insightProps: InsightLogicProps
node: PathNodeData
}

export function PathNodeLabel({ insightProps, node }: PathNodeLabelProps): JSX.Element | null {
const { pathsFilter } = useValues(pathsDataLogic(insightProps))
const { updateInsightFilter, openPersonsModal, viewPathToFunnel } = useActions(pathsDataLogic(insightProps))

const { hasAvailableFeature } = useValues(userLogic)
const hasAdvancedPaths = hasAvailableFeature(AvailableFeature.PATHS_ADVANCED)

const nodeName = pageUrl(node)
const isPath = nodeName.includes('/')

const setAsPathStart = (): void => updateInsightFilter({ startPoint: nodeName })
const setAsPathEnd = (): void => updateInsightFilter({ endPoint: nodeName })
const excludePathItem = (): void => {
updateInsightFilter({ excludeEvents: [...(pathsFilter?.excludeEvents || []), pageUrl(node, false)] })
}
const viewFunnel = (): void => {
viewPathToFunnel(node)
}
const copyName = (): void => {
void copyToClipboard(nodeName).catch(captureException)
}
const openModal = (): void => openPersonsModal({ path_end_key: node.name })

const isTruncatedPath = node.name.slice(1) === '_...'

return (
<div
className="absolute"
// eslint-disable-next-line react/forbid-dom-props
style={{
width: NODE_LABEL_WIDTH,
height: NODE_LABEL_HEIGHT,
left: node.x0 + NODE_LABEL_LEFT_OFFSET,
top: node.y0 + NODE_LABEL_TOP_OFFSET,
}}
>
<div className="flex items-center">
<Tooltip title={pageUrl(node)} placement="right">
<div className="font-semibold overflow-hidden max-h-16 text-xs break-words">
{pageUrl(node, isPath)}
</div>
</Tooltip>
{!isTruncatedPath && (
<LemonMenu
items={[
{ label: 'Set as path start', onClick: setAsPathStart },
...(hasAdvancedPaths
? [
{ label: 'Set as path end', onClick: setAsPathEnd },
{ label: 'Exclude path item', onClick: excludePathItem },
{ label: 'View funnel', onClick: viewFunnel },
]
: []),
{ label: 'Copy path item name', onClick: copyName },
]}
>
<IconEllipsis className="ml-1 cursor-pointer text-muted hover:text-default" />
</LemonMenu>
)}
</div>

<LemonButton size="xsmall" onClick={openModal} noPadding>
<span className="font-normal">{node.value}</span>
</LemonButton>
</div>
)
}
12 changes: 12 additions & 0 deletions frontend/src/scenes/paths-v2/Paths.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.Paths {
position: relative;
width: 100%;
height: 720px;
max-height: 100%;
overflow-y: hidden;

.Paths__canvas {
width: 100%;
height: 100% !important;
}
}
85 changes: 85 additions & 0 deletions frontend/src/scenes/paths-v2/PathsV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import './Paths.scss'

import { useActions, useValues } from 'kea'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
import { lightenDarkenColor } from 'lib/utils'
import { useEffect, useRef, useState } from 'react'
import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates'
import { insightLogic } from 'scenes/insights/insightLogic'

import { FunnelPathsFilter } from '~/queries/schema/schema-general'

import { PathNodeLabel } from './PathNodeLabel'
import { pathsDataLogic } from './pathsDataLogic'
import type { PathNodeData } from './pathUtils'
import { renderPaths } from './renderPaths'

export function PathsV2(): JSX.Element {
const canvasRef = useRef<HTMLDivElement>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const { width: canvasWidth, height: canvasHeight } = useResizeObserver({ ref: canvasRef })
const [nodes, setNodes] = useState<PathNodeData[]>([])

const { insightProps } = useValues(insightLogic)
const { insightQuery, paths, pathsFilter, funnelPathsFilter, insightDataLoading, insightDataError, theme } =
useValues(pathsDataLogic(insightProps))
const { openPersonsModal } = useActions(pathsDataLogic(insightProps))

useEffect(() => {
setNodes([])

// Remove the existing SVG canvas(es). The .Paths__canvas selector is crucial, as we have to be sure
// we're only removing the Paths viz and not, for example, button icons.
// Only remove canvases within this component's container
const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`)
elements?.forEach((node) => node?.parentNode?.removeChild(node))

renderPaths(
canvasRef,
canvasWidth,
canvasHeight,
paths,
pathsFilter || {},
funnelPathsFilter || ({} as FunnelPathsFilter),
setNodes,
openPersonsModal
)

// Proper cleanup
return () => {
const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`)
elements?.forEach((node) => node?.parentNode?.removeChild(node))
}
}, [paths, insightDataLoading, canvasWidth, canvasHeight, theme, pathsFilter, funnelPathsFilter])

if (insightDataError) {
return <InsightErrorState query={insightQuery} excludeDetail />
}

return (
<div className="h-full w-full overflow-auto" ref={canvasContainerRef}>
<div
ref={canvasRef}
className="Paths"
data-attr="paths-viz"
// eslint-disable-next-line react/forbid-dom-props
style={
{
'--paths-node': theme?.['preset-1'] || '#000000',
'--paths-node-hover': lightenDarkenColor(theme?.['preset-1'] || '#000000', -20),
'--paths-node-start-or-end': theme?.['preset-2'] || '#000000',
'--paths-node-start-or-end-hover': lightenDarkenColor(theme?.['preset-2'] || '#000000', -20),
'--paths-link': theme?.['preset-1'] || '#000000',
'--paths-link-hover': lightenDarkenColor(theme?.['preset-1'] || '#000000', -20),
'--paths-dropoff': 'rgba(220,53,69,0.7)',
} as React.CSSProperties
}
>
{!insightDataLoading && paths && paths.nodes.length === 0 && !insightDataError && <InsightEmptyState />}
{!insightDataError &&
nodes &&
nodes.map((node, idx) => <PathNodeLabel key={idx} node={node} insightProps={insightProps} />)}
</div>
</div>
)
}
Loading

0 comments on commit 3eb65a3

Please sign in to comment.