-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(paths): add paths-v2 visualization (#28495)
- Loading branch information
1 parent
5f918f5
commit 2846e8e
Showing
12 changed files
with
972 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
Oops, something went wrong.