Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(paths): add paths-v2 visualization #28495

Merged
merged 48 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d447c57
add feature flag for paths v2
thmsobrmlr Feb 7, 2025
a9a0ee0
move flag to insightLogic
thmsobrmlr Feb 7, 2025
8448f8a
remove paths canvas label
thmsobrmlr Feb 7, 2025
0cac6f3
remove dropoffs
thmsobrmlr Feb 7, 2025
3008f28
remove chart axis lines
thmsobrmlr Feb 7, 2025
d7ec558
adapt link opacity
thmsobrmlr Feb 7, 2025
4be7ee5
use a left aligned sankey
thmsobrmlr Feb 7, 2025
744d566
remove number from node card
thmsobrmlr Feb 7, 2025
b1b83b5
display longer part of the url
thmsobrmlr Feb 7, 2025
192588a
display all node cards
thmsobrmlr Feb 7, 2025
3f6fb60
remove HIDE_PATH_CARD_HEIGHT
thmsobrmlr Feb 7, 2025
0046377
remove id from canvas div
thmsobrmlr Feb 7, 2025
1626c94
move constants
thmsobrmlr Feb 7, 2025
4eaa016
remove more dropoff code
thmsobrmlr Feb 7, 2025
9bdd3f2
add rounded borders to nodes
thmsobrmlr Feb 7, 2025
8bb392f
expand canvas margin to account for border radius
thmsobrmlr Feb 7, 2025
7d30966
make all node labels left aligned
thmsobrmlr Feb 7, 2025
9f761b3
add space for node labels
thmsobrmlr Feb 7, 2025
c3b0baf
set node min height
thmsobrmlr Feb 7, 2025
83db0ed
add canvas padding
thmsobrmlr Feb 7, 2025
dc9d44e
remove path node card menu
thmsobrmlr Feb 7, 2025
1f49863
rename node card to node label
thmsobrmlr Feb 7, 2025
4953d87
move all node label contents inside
thmsobrmlr Feb 7, 2025
61b9f1f
move tooltip to url
thmsobrmlr Feb 7, 2025
4e55895
format label
thmsobrmlr Feb 7, 2025
c6df13d
node styling
thmsobrmlr Feb 12, 2025
57a027e
wip new hover effect
thmsobrmlr Feb 12, 2025
8db4c09
implement node hover
thmsobrmlr Feb 12, 2025
4f42545
implement link hover
thmsobrmlr Feb 12, 2025
76ea276
simplify names
thmsobrmlr Feb 12, 2025
d8e4ca5
path label styling
thmsobrmlr Feb 12, 2025
375f058
improve variable names
thmsobrmlr Feb 12, 2025
fe89470
node clicks
thmsobrmlr Feb 12, 2025
6a718d7
Merge branch 'master' into paths-v2
thmsobrmlr Feb 12, 2025
626f66a
fix lint issue
thmsobrmlr Feb 13, 2025
3e91a52
Merge branch 'master' into paths-v2
thmsobrmlr Feb 25, 2025
6ade6fb
thanks, greptile
thmsobrmlr Feb 25, 2025
9589a8f
Merge branch 'master' into paths-v2
thmsobrmlr Feb 25, 2025
4d269fa
bow to webpack/sucrase
thmsobrmlr Feb 25, 2025
b4bc4c9
Merge branch 'master' into paths-v2
thmsobrmlr Feb 25, 2025
8c5a4f7
refactor: Stop storing definitions to `hostdefinition` (#29147)
rafaeelaudibert Feb 25, 2025
c5c1bb4
fix: sonnet 3-7 (#29203)
k11kirky Feb 25, 2025
fdd9f5f
fix: Silence several TS errors from plugin-server tests (#29200)
rafaeelaudibert Feb 25, 2025
f96a819
feat: Add job that cleans up resources after an irrecoverable squash …
tkaemming Feb 25, 2025
28d497e
feat(loading-state-views): Improving messaging for long-running query…
phixMe Feb 25, 2025
43cee96
chore: Add CLAUDE.md (#29153)
fuziontech Feb 25, 2025
f09dd33
fix: Safely add livestream host (#28727)
benjackwhite Feb 25, 2025
12702d5
fix(environment): Improve project/team/env switching - feat-environme…
HamedMP Feb 25, 2025
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
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,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
RECORDINGS_AI_REGEX: 'recordings-ai-regex', // owner: @veryayskiy #team-replay
PATH_CLEANING_AI_REGEX: 'path-cleaning-ai-regex', // owner: @rafaeelaudibert #team-web-analytics
} as const
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 @@ -152,7 +153,7 @@ export function InsightVizDisplay({
/>
)
case InsightType.PATHS:
return <Paths />
return isUsingPathsV2 ? <PathsV2 /> : <Paths />
default:
return null
}
Expand Down Expand Up @@ -244,7 +245,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 @@ -349,6 +349,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).then(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'

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
Loading