diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js
deleted file mode 100644
index a2ee5a9e8a63..000000000000
--- a/assets/js/dashboard/extra/exploration.js
+++ /dev/null
@@ -1,1155 +0,0 @@
-import React, {
- useState,
- useEffect,
- useLayoutEffect,
- useRef,
- useCallback
-} from 'react'
-import LazyLoader from '../components/lazy-loader'
-import * as api from '../api'
-import { ApiError } from '../api'
-import * as url from '../util/url'
-import { Tooltip } from '../util/tooltip'
-import { useDebounce } from '../custom-hooks'
-import { useSiteContext } from '../site-context'
-import { useDashboardStateContext } from '../dashboard-state-context'
-import {
- numberShortFormatter,
- numberLongFormatter,
- percentageFormatter
-} from '../util/number-formatter'
-import { RefreshIcon, CursorIcon, FolderIcon } from '../components/icons'
-import { ChevronUpDownIcon } from '@heroicons/react/20/solid'
-import { FlagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
-import { popover } from '../components/popover'
-
-const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' }
-
-const DIRECTION_OPTIONS = [
- { value: DIRECTION.FORWARD, label: 'Starting point' },
- { value: DIRECTION.BACKWARD, label: 'End point' }
-]
-
-const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page']
-
-const MAX_VISIBLE_CANDIDATES = 10
-const MIN_GRID_COLUMNS = 3
-
-const EMPTY_JOURNEY_STATE = {
- steps: [],
- funnel: [],
- activeResults: [],
- activeFilter: '',
- // list of suggestions the user saw when picking step
- frozen: {},
- provisional: {},
- rateLimited: false
-}
-
-const EMPTY_SVG_DATA = {
- paths: [],
- width: 0,
- height: 0,
- clipY: 0,
- clipHeight: 0
-}
-
-function roundedPercentage(value, total) {
- const percentage = (value / total) * 100
- // Rounding to 2 decimal places using Math.round()
- // (https://stackoverflow.com/a/11832950)
- return Math.round((percentage + Number.EPSILON) * 100) / 100
-}
-
-function isRateLimitedError(err) {
- return err instanceof ApiError && err.status === 429
-}
-
-// Two steps are identical when their identity fields match.
-function stepsEqual(a, b) {
- return (
- a.name === b.name &&
- a.pathname === b.pathname &&
- a.includes_subpaths === b.includes_subpaths
- )
-}
-
-// Strip page-related filters from the dashboard state when a journey is
-// active - the journey itself defines the page scope.
-function dashboardStateForQuery(dashboardState, steps) {
- if (steps.length === 0) return dashboardState
- return {
- ...dashboardState,
- filters: dashboardState.filters.filter(
- ([_op, key]) => !PAGE_FILTER_KEYS.includes(key)
- )
- }
-}
-
-// Serialize steps into the wire format expected by the API.
-function stepsToJourneyParam(steps) {
- return JSON.stringify(
- steps.map(
- ({ name, pathname, includes_subpaths, subpaths_count, is_goal }) => ({
- name,
- pathname,
- includes_subpaths,
- subpaths_count,
- is_goal
- })
- )
- )
-}
-
-function maybeEmptyResults(results, activeFilter, journeyEndEvent) {
- if (
- results.length === 0 ||
- (!activeFilter &&
- results.length === 1 &&
- results[0].step.name === journeyEndEvent)
- ) {
- return []
- } else {
- return results
- }
-}
-
-// Keep only entries with index < fromIndex, discarding everything at or after.
-// Used to truncate frozen candidate snapshots when the journey is shortened.
-function truncateFrozenAt(frozen, fromIndex) {
- const result = {}
- for (const key of Object.keys(frozen)) {
- if (Number(key) < fromIndex) result[key] = frozen[key]
- }
- return result
-}
-
-// Column header label based on index and direction.
-function columnHeader(index, direction) {
- if (index === 0) {
- return direction === DIRECTION.BACKWARD ? 'End point' : 'Starting point'
- }
- const word = direction === DIRECTION.BACKWARD ? 'before' : 'after'
- return `${index} step${index === 1 ? '' : 's'} ${word}`
-}
-
-function fetchNextWithFunnel(
- site,
- dashboardState,
- steps,
- filter,
- direction,
- includeFunnel
-) {
- return api.post(
- url.apiPath(site, '/exploration/next-with-funnel'),
- dashboardStateForQuery(dashboardState, steps),
- {
- journey: stepsToJourneyParam(steps),
- search_term: filter,
- direction,
- include_funnel: includeFunnel
- }
- )
-}
-
-// x-coordinate of a column element's left or right edge in the coordinate
-// space of the scroll container, stable across horizontal scrolling.
-function columnEdgeX(colEl, side, containerRect, scrollLeft) {
- const rect = colEl.getBoundingClientRect()
- const edgeX = side === 'right' ? rect.right : rect.left
- return edgeX - containerRect.left + scrollLeft
-}
-
-// Vertical midpoint of a step row relative to the top of the container.
-function stepRowMidY(stepRowEl, containerRect) {
- const rect = stepRowEl.getBoundingClientRect()
- return (rect.top + rect.bottom) / 2 - containerRect.top
-}
-
-// SVG path for a stepped connector with rounded corners.
-function steppedPath(x1, y1, x2, y2) {
- const mx = (x1 + x2) / 2
- const dy = y2 - y1
-
- if (Math.abs(dy) < 1) {
- return `M ${x1} ${y1} H ${x2}`
- }
-
- const r = Math.min(10, Math.abs(dy) / 2)
-
- if (dy > 0) {
- return `M ${x1} ${y1} H ${mx - r} A ${r} ${r} 0 0 1 ${mx} ${y1 + r} V ${y2 - r} A ${r} ${r} 0 0 0 ${mx + r} ${y2} H ${x2}`
- } else {
- return `M ${x1} ${y1} H ${mx - r} A ${r} ${r} 0 0 0 ${mx} ${y1 - r} V ${y2 + r} A ${r} ${r} 0 0 1 ${mx + r} ${y2} H ${x2}`
- }
-}
-
-// Clip rect that keeps connectors inside the list area,
-// preventing them from bleeding into column headers.
-function listClipRect(container, containerRect) {
- const firstList = container.querySelector('[data-exploration-list]')
- if (!firstList) return { y: 0, height: container.clientHeight }
- const rect = firstList.getBoundingClientRect()
- return { y: rect.top - containerRect.top, height: rect.height }
-}
-
-function computeConnectors(container, steps) {
- const containerRect = container.getBoundingClientRect()
- const paths = []
-
- for (let i = 0; i < steps.length - 1; i++) {
- // Query by explicit column index so DOM order never causes a mismatch.
- const colA = container.querySelector(`[data-exploration-column="${i}"]`)
- const colB = container.querySelector(`[data-exploration-column="${i + 1}"]`)
- const rowA = container.querySelector(`[data-exploration-step="${i}"]`)
- const rowB = container.querySelector(`[data-exploration-step="${i + 1}"]`)
-
- if (colA && colB && rowA && rowB) {
- const x1 = columnEdgeX(colA, 'right', containerRect, container.scrollLeft)
- const x2 = columnEdgeX(colB, 'left', containerRect, container.scrollLeft)
- const y1 = stepRowMidY(rowA, containerRect)
- const y2 = stepRowMidY(rowB, containerRect)
- paths.push(steppedPath(x1, y1, x2, y2))
- }
- }
-
- const clip = listClipRect(container, containerRect)
-
- return {
- paths,
- width: container.scrollWidth,
- height: container.clientHeight,
- clipY: clip.y,
- clipHeight: clip.height
- }
-}
-
-// layoutKey is bumped whenever the DOM may have changed in a way that is not
-// reflected by a steps reference change, e.g. a dashboardState update. It
-// is the caller's responsibility to increment it after such changes.
-function PathConnectors({ steps, containerRef, layoutKey }) {
- const [svgData, setSvgData] = useState(EMPTY_SVG_DATA)
-
- const recalculate = useCallback(() => {
- const container = containerRef.current
- if (container) setSvgData(computeConnectors(container, steps))
- }, [steps, containerRef])
-
- useLayoutEffect(() => {
- const container = containerRef.current
-
- if (!container || steps.length < 2) {
- setSvgData(EMPTY_SVG_DATA)
- return
- }
-
- setSvgData(computeConnectors(container, steps))
-
- const observer = new ResizeObserver(recalculate)
- observer.observe(container)
- window.addEventListener('resize', recalculate)
-
- const lists = Array.from(
- container.querySelectorAll('[data-exploration-list]')
- )
- lists.forEach((list) => list.addEventListener('scroll', recalculate))
-
- return () => {
- observer.disconnect()
- window.removeEventListener('resize', recalculate)
- lists.forEach((list) => list.removeEventListener('scroll', recalculate))
- }
- // layoutKey is intentionally included: it forces this effect to re-run
- // and recalculate geometry after DOM updates that don't change steps.
- }, [steps, containerRef, recalculate, layoutKey])
-
- if (svgData.paths.length === 0) return null
-
- return (
-
- )
-}
-
-function DirectionDropdown({ direction, onChange }) {
- const [open, setOpen] = useState(false)
- const containerRef = useRef(null)
-
- useEffect(() => {
- if (!open) return
- function onClickOutside(e) {
- if (containerRef.current && !containerRef.current.contains(e.target)) {
- setOpen(false)
- }
- }
- document.addEventListener('mousedown', onClickOutside)
- return () => document.removeEventListener('mousedown', onClickOutside)
- }, [open])
-
- const currentLabel = DIRECTION_OPTIONS.find(
- (o) => o.value === direction
- )?.label
-
- return (
-
-
-
- {open && (
-
- {DIRECTION_OPTIONS.map(({ value, label }) => (
-
- ))}
-
- )}
-
- )
-}
-
-function CandidateCard({
- step,
- visitors,
- isSelected,
- isDimmed,
- selectedVisitors,
- selectedConversionRate,
- stepMaxVisitors,
- colIndex,
- onSelect
-}) {
- const { explorationJourneyEndEvent: journeyEndEvent } = useSiteContext()
- const isJourneyEnd = step.name === journeyEndEvent
- const isCustomEvent =
- step.name !== 'pageview' && step.name !== journeyEndEvent
- const isGoal = step.is_goal
-
- const visitorsToShow =
- isSelected && selectedVisitors !== null ? selectedVisitors : visitors
- const barWidth =
- isSelected && selectedConversionRate !== null
- ? Math.max(1, selectedConversionRate)
- : Math.max(1, roundedPercentage(visitors, stepMaxVisitors))
-
- const textColor = isDimmed
- ? 'text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-400'
- : 'text-gray-900 dark:text-gray-100'
-
- const barBg = isJourneyEnd
- ? 'bg-gray-100 dark:bg-gray-700/50'
- : isSelected
- ? 'bg-indigo-150 group-hover:bg-indigo-150 dark:bg-indigo-500/50 dark:group-hover:bg-indigo-500/50'
- : isDimmed
- ? 'bg-indigo-50/80 dark:bg-indigo-500/10 group-hover:bg-indigo-100 dark:group-hover:bg-indigo-500/25'
- : 'bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-500/20 dark:group-hover:bg-indigo-500/30'
-
- const rowBg = isSelected
- ? 'bg-gray-100/60 dark:bg-gray-850'
- : 'hover:bg-gray-100/60 dark:hover:bg-gray-850'
-
- const pointer = isJourneyEnd ? 'pointer-events-none' : ''
-
- const onSelectHandler = isJourneyEnd ? () => {} : onSelect
-
- const iconClassName = `size-4 shrink-0 ${textColor}`
- const iconTooltipInfo =
- isCustomEvent || isGoal
- ? isGoal
- ? 'Goal'
- : 'Custom event'
- : step.includes_subpaths
- ? `Grouped pages: ${numberShortFormatter(step.subpaths_count)} pages with this prefix`
- : 'Pageview'
-
- const iconSvg =
- isCustomEvent || isGoal ? (
-
- ) : step.includes_subpaths ? (
-
- ) : null
-
- const iconElement = !iconSvg ? null : (
-
- {iconSvg}
-
- )
-
- return (
-
-
-
- )
-}
-
-function VisitorsMetric({ visitors }) {
- const shortNumber = numberShortFormatter(visitors)
- const longNumber = numberLongFormatter(visitors)
- const showTooltip = shortNumber !== longNumber
-
- if (showTooltip) {
- return (
-
- {shortNumber}
-
- )
- } else {
- return {shortNumber}
- }
-}
-
-function ColumnEmptyState({
- active,
- filter,
- colIndex,
- direction,
- rateLimited,
- onRetry
-}) {
- if (active && rateLimited) {
- return (
-
- Too many requests, please wait a moment and{' '}
-
-
- )
- }
-
- if (!active) {
- const prompt =
- colIndex === 1
- ? direction === DIRECTION.BACKWARD
- ? 'Select an end point to continue'
- : 'Select a starting point to continue'
- : 'Select an event to continue'
-
- return (
-
-
- {prompt}
-
- )
- }
-
- if (filter) {
- return (
-
-
- No events found
-
- )
- }
-
- return (
-
-
- No further steps found for the selected period and filters
-
- )
-}
-
-function MaxDepthColumn({ colIndex, header }) {
- const { explorationMaxJourneySteps: maxJourneySteps } = useSiteContext()
- return (
-
-
-
- {header}
-
-
-
-
-
- {`You've reached the maximum journey depth of ${maxJourneySteps} steps.`}
-
-
-
- )
-}
-
-function ExplorationColumn({
- colIndex,
- direction,
- onDirectionChange,
- header,
- headerConversionRate,
- active,
- loading,
- loadingInBackground,
- results,
- selected,
- selectedVisitors,
- selectedConversionRate,
- maxVisitors,
- filter,
- onFilterChange,
- onSelect,
- rateLimited,
- onRetry
-}) {
- const debouncedFilterChange = useDebounce((e) =>
- onFilterChange(e.target.value)
- )
-
- // When a step is selected but there are no candidate results,
- // synthesise a single-item list from the funnel data so
- // the selected step is still rendered in the column.
- const listItems =
- selected && results.length === 0
- ? [{ step: selected, visitors: selectedVisitors ?? 0 }]
- : results.slice(0, MAX_VISIBLE_CANDIDATES)
-
- const stepMaxVisitors = maxVisitors ?? results[0]?.visitors
-
- const showSearch = active && !selected && (results.length > 0 || filter)
-
- const onSelectHandler = loadingInBackground ? () => {} : onSelect
-
- return (
-
-
- {onDirectionChange ? (
-
- ) : (
-
- {header}
-
- )}
-
- {showSearch && (
-
- )}
-
- {!showSearch && headerConversionRate && (
-
- {headerConversionRate}
-
- )}
-
-
- {loading ? (
-
- ) : listItems.length === 0 ? (
-
-
-
- ) : (
-
- {listItems.map(({ step, visitors }) => (
-
- ))}
-
- )}
-
- )
-}
-
-// Compute provisional funnel entries for a newly selected step so the UI
-// displays sensible values immediately before the API responds.
-function provisionalEntry(step, columnIndex, sourceResults, existingFunnel) {
- const match = sourceResults.find(({ step: s }) => stepsEqual(s, step))
- if (!match) return {}
-
- const firstStepVisitors = existingFunnel[0]?.visitors ?? match.visitors
- const conversionRate = roundedPercentage(match.visitors, firstStepVisitors)
- return {
- [columnIndex]: { visitors: match.visitors, conversion_rate: conversionRate }
- }
-}
-
-// useExplorationData manages all async data fetching, cancellation, and
-// journey state.
-function useExplorationData(site, dashboardState, inViewport) {
- const {
- explorationMaxJourneySteps: maxJourneySteps,
- explorationJourneyEndEvent: journeyEndEvent
- } = useSiteContext()
- const [state, setState] = useState(EMPTY_JOURNEY_STATE)
- const [activeLoading, setActiveLoading] = useState(false)
- const [retryCount, setRetryCount] = useState(0)
- const [directionKey, setDirectionKey] = useState(0)
- // Incremented whenever the dashboardState or site changes so that
- // PathConnectors re-runs its layout effect and recalculates connector
- // geometry against the freshly rendered DOM. Steps alone do not change
- // on a context switch, so without this the SVG paths would be stale.
- const [layoutKey, setLayoutKey] = useState(0)
-
- // Ref-copies of the previous dependency values so the main effect can detect
- // which dimension changed without adding them to the dep array.
- const prevStepsRef = useRef(state.steps)
- const prevDirectionRef = useRef(DIRECTION.FORWARD)
- const prevDashboardStateRef = useRef(dashboardState)
-
- // Incremented on every user-driven journey mutation. Stale async callbacks
- // capture the version at dispatch time and abort if it no longer matches.
- const journeyVersionRef = useRef(0)
-
- // Direction lives in a ref so that changing it resets state in one render
- // without causing a double-fetch from a direction state update racing with
- // a steps state update.
- const directionRef = useRef(DIRECTION.FORWARD)
-
- const selectStep = useCallback((columnIndex, step) => {
- ++journeyVersionRef.current
-
- setState((prev) => {
- if (step === null) {
- // Deselect: truncate journey at columnIndex.
- return {
- ...prev,
- steps: prev.steps.slice(0, columnIndex),
- activeResults: [],
- activeFilter: '',
- frozen: truncateFrozenAt(prev.frozen, columnIndex + 1),
- provisional: {},
- rateLimited: false
- }
- }
-
- // Select: determine source results for provisional values.
- const sourceResults =
- columnIndex === prev.steps.length
- ? prev.activeResults
- : (prev.frozen[columnIndex] ?? [])
-
- const newFrozen =
- columnIndex === prev.steps.length
- ? {
- ...truncateFrozenAt(prev.frozen, columnIndex),
- [columnIndex]: prev.activeResults
- }
- : truncateFrozenAt(prev.frozen, columnIndex + 1)
-
- return {
- ...prev,
- steps: [...prev.steps.slice(0, columnIndex), step],
- activeResults: [],
- activeFilter: '',
- frozen: newFrozen,
- provisional: provisionalEntry(
- step,
- columnIndex,
- sourceResults,
- prev.funnel
- ),
- rateLimited: false
- }
- })
- }, [])
-
- const reset = useCallback(() => {
- ++journeyVersionRef.current
- setActiveLoading(true)
- setState(EMPTY_JOURNEY_STATE)
- }, [])
-
- const setDirection = useCallback((newDirection) => {
- if (newDirection === directionRef.current) return
- directionRef.current = newDirection
- ++journeyVersionRef.current
- setState(EMPTY_JOURNEY_STATE)
- setDirectionKey((k) => k + 1)
- }, [])
-
- const setActiveFilter = useCallback((filter) => {
- setState((prev) => ({ ...prev, activeFilter: filter }))
- }, [])
-
- // Frozen candidate lists were fetched against a specific site and dashboard
- // filter context. When either changes the cached candidates become stale, so
- // drop them. We also bump layoutKey so PathConnectors recalculates geometry
- // after the DOM settles. Skip the initial run to avoid clobbering freshly
- // populated state on mount.
- const isFirstContextChangeRef = useRef(true)
- useEffect(() => {
- if (isFirstContextChangeRef.current) {
- isFirstContextChangeRef.current = false
- return
- }
- ++journeyVersionRef.current
- setState((prev) => ({ ...prev, frozen: {} }))
- setLayoutKey((k) => k + 1)
- }, [site, dashboardState])
-
- useEffect(() => {
- if (!inViewport) return
-
- const currentDirection = directionRef.current
- const steps = state.steps
- const activeFilter = state.activeFilter
-
- if (steps.length >= maxJourneySteps) {
- setActiveLoading(false)
- return
- }
-
- const journeyChanged =
- prevStepsRef.current !== steps ||
- prevDirectionRef.current !== currentDirection ||
- prevDashboardStateRef.current !== dashboardState
-
- prevStepsRef.current = steps
- prevDirectionRef.current = currentDirection
- prevDashboardStateRef.current = dashboardState
-
- // Capture the version at effect-dispatch time so stale responses are
- // discarded if the user mutates the journey before the response arrives.
- const capturedVersion = journeyVersionRef.current
- const isStale = () => journeyVersionRef.current !== capturedVersion
-
- setActiveLoading(true)
-
- const includeFunnel = journeyChanged && steps.length > 0
-
- if (journeyChanged && steps.length === 0) {
- setState((prev) => ({ ...prev, funnel: [] }))
- }
-
- fetchNextWithFunnel(
- site,
- dashboardState,
- steps,
- activeFilter,
- currentDirection,
- includeFunnel
- )
- .then((response) => {
- if (isStale()) return
- setState((prev) => {
- const next = {
- ...prev,
- activeResults: maybeEmptyResults(
- response?.next ?? [],
- prev.activeFilter,
- journeyEndEvent
- ),
- rateLimited: false
- }
- if (includeFunnel) {
- let newFunnel = response?.funnel ?? []
- next.provisional = {}
-
- // Truncate the funnel at first 0-visitors step.
- // This happens when the dashboard state narrows (e.g. shorter time range)
- // and the existing steps can no longer be fulfilled.
- const firstZeroIdx = newFunnel.findIndex((f) => f.visitors === 0)
- if (firstZeroIdx !== -1) {
- newFunnel = newFunnel.slice(0, firstZeroIdx)
- next.steps = prev.steps.slice(0, firstZeroIdx)
- next.frozen = truncateFrozenAt(prev.frozen, firstZeroIdx)
- next.activeResults = []
- }
-
- next.funnel = newFunnel
-
- // Sync subpaths_count on existing steps from the refreshed funnel
- // so that step identity stays consistent with what the API now
- // reports for the current period. Without this, a period change
- // leaves stale subpaths_count values in steps while frozen
- // candidates and new results carry fresh values, causing duplicate
- // entries and double-highlighted rows.
- const currentSteps = next.steps ?? prev.steps
- if (newFunnel.length > 0 && currentSteps.length > 0) {
- const synced = currentSteps.map((s, idx) =>
- newFunnel[idx]
- ? { ...s, subpaths_count: newFunnel[idx].step.subpaths_count }
- : s
- )
- // Only replace the steps reference when something actually changed
- // to avoid re-triggering the main effect (steps is a dep array entry).
- const changed = synced.some(
- (s, idx) =>
- s.subpaths_count !== currentSteps[idx].subpaths_count
- )
- if (changed) next.steps = synced
- }
- }
- return next
- })
- })
- .catch((err) => {
- if (isStale()) return
- if (isRateLimitedError(err)) {
- setState((prev) => ({
- ...prev,
- frozen: truncateFrozenAt(prev.frozen, prev.steps.length),
- rateLimited: true,
- activeResults: [],
- ...(includeFunnel ? { provisional: {} } : {})
- }))
- } else {
- setState((prev) => ({
- ...prev,
- frozen: truncateFrozenAt(prev.frozen, prev.steps.length),
- activeResults: [],
- ...(includeFunnel ? { funnel: [] } : {})
- }))
- }
- })
- .finally(() => {
- if (!isStale()) setActiveLoading(false)
- })
- }, [
- site,
- dashboardState,
- state.steps,
- state.activeFilter,
- inViewport,
- retryCount,
- directionKey
- ])
- // direction is intentionally excluded from the dep array. It lives in a ref
- // and resets state, which does appear above, so the state update itself
- // drives the re-run without double-firing.
-
- const retry = useCallback(() => {
- setState((prev) => ({ ...prev, rateLimited: false }))
- setRetryCount((c) => c + 1)
- }, [])
-
- return {
- state,
- direction: directionRef.current,
- activeLoading,
- layoutKey,
- rateLimited: state.rateLimited,
- selectStep,
- reset,
- retry,
- setDirection,
- setActiveFilter
- }
-}
-
-// Scrolls the active column into view whenever the journey length changes.
-function useScrollActiveColumnIntoView(containerRef, stepsLength) {
- const prevLengthRef = useRef(0)
-
- useEffect(() => {
- const el = containerRef.current
- if (!el || stepsLength === prevLengthRef.current) {
- prevLengthRef.current = stepsLength
- return
- }
- prevLengthRef.current = stepsLength
-
- const activeColumn = el.querySelector(
- `[data-exploration-column="${stepsLength}"]`
- )
- if (activeColumn) {
- const { left: colLeft, right: colRight } =
- activeColumn.getBoundingClientRect()
- const { left: containerLeft, right: containerRight } =
- el.getBoundingClientRect()
- if (colRight > containerRight || colLeft < containerLeft) {
- el.scrollTo({
- left: el.scrollLeft + colLeft - containerLeft,
- behavior: 'smooth'
- })
- }
- } else {
- el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' })
- }
- }, [containerRef, stepsLength])
-}
-
-export function FunnelExploration() {
- const site = useSiteContext()
- const { dashboardState } = useDashboardStateContext()
- const [inViewport, setInViewport] = useState(false)
- const maxJourneySteps = site.explorationMaxJourneySteps
-
- const {
- state,
- direction,
- activeLoading,
- layoutKey,
- rateLimited,
- selectStep,
- reset,
- retry,
- setDirection,
- setActiveFilter
- } = useExplorationData(site, dashboardState, inViewport)
-
- const { steps, funnel, activeResults, activeFilter, frozen, provisional } =
- state
-
- const containerRef = useRef(null)
- useScrollActiveColumnIntoView(containerRef, steps.length)
-
- const initialLoading = !inViewport || (steps.length === 0 && activeLoading)
- const journeyEnded =
- !activeLoading && activeResults.length === 0 && steps.length >= 1
- const activeColumnIndex = steps.length
-
- const numColumns = initialLoading
- ? 1
- : journeyEnded || (activeLoading && steps.length === 1)
- ? steps.length + 1
- : Math.max(steps.length + 1, MIN_GRID_COLUMNS)
-
- const gridColumns = Math.max(numColumns, MIN_GRID_COLUMNS)
-
- const noData =
- !initialLoading &&
- !activeLoading &&
- steps.length === 0 &&
- funnel.length === 0 &&
- activeResults.length === 0 &&
- !activeFilter &&
- !rateLimited
-
- const lastFunnelStep = funnel.length >= 2 ? funnel[funnel.length - 1] : null
- const overallConversionRate = lastFunnelStep?.conversion_rate ?? null
- const overallConversionVisitors = lastFunnelStep?.visitors ?? null
-
- return (
- setInViewport(true)}>
-
-
-
- {funnel.length >= 2
- ? `${funnel.length}-step user journey`
- : 'Explore user journeys'}
-
-
- {overallConversionRate != null && (
-
-
-
- CR: {percentageFormatter(parseFloat(overallConversionRate))}{' '}
-
-
- ({numberShortFormatter(overallConversionVisitors)})
-
-
-
- |
-
-
- )}
-
-
Deselect all}
- className={
- steps.length === 0 ? 'invisible pointer-events-none' : ''
- }
- >
-
-
-
-
- {noData ? (
-
- No data yet
-
- ) : (
-
- {Array.from({ length: numColumns }, (_, i) => {
- const isActive = i === activeColumnIndex
- const isReachable = steps.length >= i
-
- const colFilter = isActive ? activeFilter : ''
- const colFrozen = frozen[i] ?? []
-
- const colResults =
- isActive && (activeResults.length > 0 || colFilter)
- ? activeResults
- : colFrozen
- const colLoadingInBackground =
- isActive && (initialLoading || activeLoading)
- const colLoading =
- colLoadingInBackground && (!frozen[i] || !!colFilter)
-
- const colSelectedVisitors =
- provisional[i]?.visitors ?? funnel[i]?.visitors ?? null
- const colSelectedConversionRate =
- provisional[i]?.conversion_rate ??
- funnel[i]?.conversion_rate ??
- null
-
- const colHeaderConversionRate =
- funnel[i]?.conversion_rate != null
- ? i === 0
- ? '100%'
- : `${parseFloat(funnel[i].conversion_rate).toFixed(1)}%`
- : null
-
- if (isActive && steps.length >= maxJourneySteps) {
- return (
-
- )
- }
-
- return (
-
{}}
- onSelect={(step) => selectStep(i, step)}
- rateLimited={isActive && rateLimited}
- onRetry={retry}
- />
- )
- })}
-
-
-
- )}
-
-
- )
-}
-
-export default FunnelExploration
diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts
new file mode 100644
index 000000000000..ee2363b1968f
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/constants.ts
@@ -0,0 +1,20 @@
+export type ExplorationDirection = 'forward' | 'backward'
+
+type ExplorationDirectionOption = {
+ value: ExplorationDirection
+ label: string
+}
+export const DIRECTION: { [label: string]: ExplorationDirection } = {
+ FORWARD: 'forward',
+ BACKWARD: 'backward'
+}
+
+export const DIRECTION_OPTIONS: ExplorationDirectionOption[] = [
+ { value: DIRECTION.FORWARD, label: 'Starting point' },
+ { value: DIRECTION.BACKWARD, label: 'End point' }
+]
+
+export const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page']
+
+export const MAX_VISIBLE_CANDIDATES = 10
+export const MIN_GRID_COLUMNS = 3
diff --git a/assets/js/dashboard/extra/exploration/exploration-column.tsx b/assets/js/dashboard/extra/exploration/exploration-column.tsx
new file mode 100644
index 000000000000..ac6450d127fb
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/exploration-column.tsx
@@ -0,0 +1,434 @@
+import React, { useState, useEffect, useRef, ReactNode, RefObject } from 'react'
+import { Tooltip } from '../../util/tooltip'
+import { useSiteContext } from '../../site-context'
+import { useDebounce } from '../../custom-hooks'
+import {
+ numberShortFormatter,
+ numberLongFormatter
+} from '../../util/number-formatter'
+import { CursorIcon, FolderIcon } from '../../components/icons'
+import { popover } from '../../components/popover'
+import { ChevronUpDownIcon } from '@heroicons/react/20/solid'
+import { FlagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
+import { roundedPercentage } from './helpers'
+import { journeyStepsEqual, JourneyStep, JourneySuggestion } from './journey'
+import {
+ DIRECTION,
+ DIRECTION_OPTIONS,
+ MAX_VISIBLE_CANDIDATES,
+ ExplorationDirection
+} from './constants'
+
+function DirectionDropdown({
+ direction,
+ onChange
+}: {
+ direction: ExplorationDirection
+ onChange: (direction: ExplorationDirection) => void
+}): ReactNode {
+ const [open, setOpen] = useState(false)
+ const containerRef: RefObject = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onClickOutside(e: Event) {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as HTMLElement)
+ ) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', onClickOutside)
+ return () => document.removeEventListener('mousedown', onClickOutside)
+ }, [open])
+
+ const currentLabel = DIRECTION_OPTIONS.find(
+ (o) => o.value === direction
+ )?.label
+
+ return (
+
+
+
+ {open && (
+
+ {DIRECTION_OPTIONS.map(({ value, label }) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function CandidateCard({
+ step,
+ visitors,
+ isSelected,
+ isDimmed,
+ selectedVisitors,
+ selectedConversionRate,
+ stepMaxVisitors,
+ colIndex,
+ onSelect
+}: {
+ step: JourneyStep
+ visitors: number
+ isSelected: boolean
+ isDimmed: boolean
+ selectedVisitors: number
+ selectedConversionRate: number
+ stepMaxVisitors: number
+ colIndex: number
+ onSelect: (step: JourneyStep | null) => void
+}): ReactNode {
+ const { explorationJourneyEndEvent: journeyEndEvent } = useSiteContext()
+ const isJourneyEnd = step.name === journeyEndEvent
+ const isCustomEvent =
+ step.name !== 'pageview' && step.name !== journeyEndEvent
+ const isGoal = step.is_goal
+
+ const visitorsToShow =
+ isSelected && selectedVisitors !== null ? selectedVisitors : visitors
+ const barWidth =
+ isSelected && selectedConversionRate !== null
+ ? Math.max(1, selectedConversionRate)
+ : Math.max(1, roundedPercentage(visitors, stepMaxVisitors))
+
+ const textColor = isDimmed
+ ? 'text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-400'
+ : 'text-gray-900 dark:text-gray-100'
+
+ const barBg = isJourneyEnd
+ ? 'bg-gray-100 dark:bg-gray-700/50'
+ : isSelected
+ ? 'bg-indigo-150 group-hover:bg-indigo-150 dark:bg-indigo-500/50 dark:group-hover:bg-indigo-500/50'
+ : isDimmed
+ ? 'bg-indigo-50/80 dark:bg-indigo-500/10 group-hover:bg-indigo-100 dark:group-hover:bg-indigo-500/25'
+ : 'bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-500/20 dark:group-hover:bg-indigo-500/30'
+
+ const rowBg = isSelected
+ ? 'bg-gray-100/60 dark:bg-gray-850'
+ : 'hover:bg-gray-100/60 dark:hover:bg-gray-850'
+
+ const pointer = isJourneyEnd ? 'pointer-events-none' : ''
+
+ const onSelectHandler = isJourneyEnd ? () => {} : onSelect
+
+ const iconClassName = `size-4 shrink-0 ${textColor}`
+ const iconTooltipInfo =
+ isCustomEvent || isGoal
+ ? isGoal
+ ? 'Goal'
+ : 'Custom event'
+ : step.includes_subpaths
+ ? `Grouped pages: ${numberShortFormatter(step.subpaths_count)} pages with this prefix`
+ : 'Pageview'
+
+ const iconSvg =
+ isCustomEvent || isGoal ? (
+
+ ) : step.includes_subpaths ? (
+
+ ) : null
+
+ const iconElement = !iconSvg ? null : (
+
+ {iconSvg}
+
+ )
+
+ return (
+
+
+
+ )
+}
+
+function VisitorsMetric({ visitors }: { visitors: number }): ReactNode {
+ const shortNumber = numberShortFormatter(visitors)
+ const longNumber = numberLongFormatter(visitors)
+ const showTooltip = shortNumber !== longNumber
+
+ if (showTooltip) {
+ return (
+
+ {shortNumber}
+
+ )
+ } else {
+ return {shortNumber}
+ }
+}
+
+function ColumnEmptyState({
+ active,
+ filter,
+ colIndex,
+ direction,
+ rateLimited,
+ onRetry
+}: {
+ active: boolean
+ filter: string
+ colIndex: number
+ direction: ExplorationDirection
+ rateLimited: boolean
+ onRetry: () => void
+}): ReactNode {
+ if (active && rateLimited) {
+ return (
+
+ Too many requests, please wait a moment and{' '}
+
+
+ )
+ }
+
+ if (!active) {
+ const prompt =
+ colIndex === 1
+ ? direction === DIRECTION.BACKWARD
+ ? 'Select an end point to continue'
+ : 'Select a starting point to continue'
+ : 'Select an event to continue'
+
+ return (
+
+
+ {prompt}
+
+ )
+ }
+
+ if (filter) {
+ return (
+
+
+ No events found
+
+ )
+ }
+
+ return (
+
+
+ No further steps found for the selected period and filters
+
+ )
+}
+
+export function MaxDepthColumn({
+ colIndex,
+ header
+}: {
+ colIndex: number
+ header: string
+}): ReactNode {
+ const { explorationMaxJourneySteps: maxJourneySteps } = useSiteContext()
+ return (
+
+
+
+ {header}
+
+
+
+
+
+ {`You've reached the maximum journey depth of ${maxJourneySteps} steps.`}
+
+
+
+ )
+}
+
+export function ExplorationColumn({
+ colIndex,
+ direction,
+ onDirectionChange,
+ header,
+ headerConversionRate,
+ active,
+ loading,
+ loadingInBackground,
+ results,
+ selected,
+ selectedVisitors,
+ selectedConversionRate,
+ maxVisitors,
+ filter,
+ onFilterChange,
+ onSelect,
+ rateLimited,
+ onRetry
+}: {
+ colIndex: number
+ direction: ExplorationDirection
+ onDirectionChange: ((direction: ExplorationDirection) => void) | undefined
+ header: string
+ headerConversionRate: string | null
+ active: boolean
+ loading: boolean
+ loadingInBackground: boolean
+ results: JourneySuggestion[]
+ selected: JourneyStep
+ selectedVisitors: number
+ selectedConversionRate: number
+ maxVisitors: number
+ filter: string
+ onFilterChange: (filter: string) => void
+ onSelect: (step: JourneyStep | null) => void
+ rateLimited: boolean
+ onRetry: () => void
+}): ReactNode {
+ const debouncedFilterChange = useDebounce((e: InputEvent) =>
+ onFilterChange((e.target as HTMLInputElement).value)
+ )
+
+ // When a step is selected but there are no candidate results,
+ // synthesise a single-item list from the funnel data so
+ // the selected step is still rendered in the column.
+ const listItems =
+ selected && results.length === 0
+ ? [{ step: selected, visitors: selectedVisitors ?? 0 }]
+ : results.slice(0, MAX_VISIBLE_CANDIDATES)
+
+ const stepMaxVisitors = maxVisitors ?? results[0]?.visitors
+
+ const showSearch = active && !selected && (results.length > 0 || filter)
+
+ const onSelectHandler = loadingInBackground ? () => {} : onSelect
+
+ return (
+
+
+ {onDirectionChange ? (
+
+ ) : (
+
+ {header}
+
+ )}
+
+ {showSearch && (
+
+ )}
+
+ {!showSearch && headerConversionRate && (
+
+ {headerConversionRate}
+
+ )}
+
+
+ {loading ? (
+
+ ) : listItems.length === 0 ? (
+
+
+
+ ) : (
+
+ {listItems.map(({ step, visitors }) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts
new file mode 100644
index 000000000000..ad3ce6a79c2d
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/exploration-state.ts
@@ -0,0 +1,280 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { ApiError } from '../../api'
+import * as api from '../../api'
+import * as url from '../../util/url'
+import { useSiteContext, PlausibleSite } from '../../site-context'
+import { DashboardState } from '../../dashboard-state'
+import {
+ emptyJourney,
+ toggleJourneyStep,
+ setJourneyActiveFilter,
+ clearJourneyFrozen,
+ clearJourneyFunnel,
+ clearJourneyRateLimit,
+ updateJourneyOnSuccess,
+ updateJourneyOnError,
+ updateJourneyOnRateLimitError,
+ JourneyStep,
+ Journey,
+ JourneySuggestion,
+ FunnelStep
+} from './journey'
+import { DIRECTION, PAGE_FILTER_KEYS, ExplorationDirection } from './constants'
+
+export type ExplorationData = {
+ journey: Journey
+ direction: ExplorationDirection
+ activeLoading: boolean
+ layoutKey: number
+ rateLimited: boolean
+ selectStep: (columnIndex: number, step: JourneyStep | null) => void
+ reset: () => void
+ retry: () => void
+ setDirection: (direction: ExplorationDirection) => void
+ setActiveFilter: (filter: string) => void
+}
+
+type ExplorationResponse = {
+ next: JourneySuggestion[] | null
+ funnel: FunnelStep[] | null
+} | null
+
+function isRateLimitedError(err: Error): boolean {
+ return err instanceof ApiError && err.status === 429
+}
+
+// Strip page-related filters from the dashboard state when a journey is
+// active - the journey itself defines the page scope.
+function dashboardStateForQuery(
+ dashboardState: DashboardState,
+ steps: JourneyStep[]
+): DashboardState {
+ if (steps.length === 0) return dashboardState
+ return {
+ ...dashboardState,
+ filters: dashboardState.filters.filter(
+ ([_op, key]) => !PAGE_FILTER_KEYS.includes(key)
+ )
+ }
+}
+
+// Serialize steps into the wire format expected by the API.
+function stepsToJourneyParam(steps: JourneyStep[]): string {
+ return JSON.stringify(
+ steps.map(
+ ({ name, pathname, includes_subpaths, subpaths_count, is_goal }) => ({
+ name,
+ pathname,
+ includes_subpaths,
+ subpaths_count,
+ is_goal
+ })
+ )
+ )
+}
+
+function fetchNextWithFunnel(
+ site: PlausibleSite,
+ dashboardState: DashboardState,
+ steps: JourneyStep[],
+ filter: string,
+ direction: ExplorationDirection,
+ includeFunnel: boolean
+): Promise {
+ return api.post(
+ url.apiPath(site, '/exploration/next-with-funnel'),
+ dashboardStateForQuery(dashboardState, steps),
+ {
+ journey: stepsToJourneyParam(steps),
+ search_term: filter,
+ direction,
+ include_funnel: includeFunnel
+ }
+ )
+}
+
+// useExplorationData manages all async data fetching, cancellation, and
+// journey state.
+export function useExplorationData({
+ site,
+ dashboardState,
+ inViewport
+}: {
+ site: PlausibleSite
+ dashboardState: DashboardState
+ inViewport: boolean
+}): ExplorationData {
+ const {
+ explorationMaxJourneySteps: maxJourneySteps,
+ explorationJourneyEndEvent: journeyEndEvent
+ } = useSiteContext()
+ const [journey, setJourney] = useState(emptyJourney)
+ const [activeLoading, setActiveLoading] = useState(false)
+ const [retryCount, setRetryCount] = useState(0)
+ const [directionKey, setDirectionKey] = useState(0)
+ // Incremented whenever the dashboardState or site changes so that
+ // PathConnectors re-runs its layout effect and recalculates connector
+ // geometry against the freshly rendered DOM. Steps alone do not change
+ // on a context switch, so without this the SVG paths would be stale.
+ const [layoutKey, setLayoutKey] = useState(0)
+
+ // Ref-copies of the previous dependency values so the main effect can detect
+ // which dimension changed without adding them to the dep array.
+ const prevStepsRef = useRef(journey.steps)
+ const prevDirectionRef = useRef(DIRECTION.FORWARD)
+ const prevDashboardStateRef = useRef(dashboardState)
+
+ // Incremented on every user-driven journey mutation. Stale async callbacks
+ // capture the version at dispatch time and abort if it no longer matches.
+ const journeyVersionRef = useRef(0)
+
+ // Direction lives in a ref so that changing it resets state in one render
+ // without causing a double-fetch from a direction state update racing with
+ // a steps state update.
+ const directionRef = useRef(DIRECTION.FORWARD)
+
+ const selectStep = useCallback(
+ (columnIndex: number, step: JourneyStep | null) => {
+ journeyVersionRef.current++
+ setJourney((journey) =>
+ toggleJourneyStep({ journey, columnIndex, newStep: step })
+ )
+ },
+ []
+ )
+
+ const reset = useCallback(() => {
+ ++journeyVersionRef.current
+ setActiveLoading(true)
+ setJourney(emptyJourney)
+ }, [])
+
+ const setDirection = useCallback((newDirection: ExplorationDirection) => {
+ if (newDirection === directionRef.current) return
+ directionRef.current = newDirection
+ ++journeyVersionRef.current
+ setJourney(emptyJourney)
+ setDirectionKey((k) => k + 1)
+ }, [])
+
+ const setActiveFilter = useCallback((filter: string) => {
+ setJourney((journey) => setJourneyActiveFilter({ journey, filter }))
+ }, [])
+
+ // Frozen candidate lists were fetched against a specific site and dashboard
+ // filter context. When either changes the cached candidates become stale, so
+ // drop them. We also bump layoutKey so PathConnectors recalculates geometry
+ // after the DOM settles. Skip the initial run to avoid clobbering freshly
+ // populated state on mount.
+ const isFirstContextChangeRef = useRef(true)
+ useEffect(() => {
+ if (isFirstContextChangeRef.current) {
+ isFirstContextChangeRef.current = false
+ return
+ }
+ ++journeyVersionRef.current
+ setJourney(clearJourneyFrozen)
+ setLayoutKey((k) => k + 1)
+ }, [site, dashboardState])
+
+ useEffect(() => {
+ if (!inViewport) return
+
+ const currentDirection = directionRef.current
+ const steps = journey.steps
+ const activeFilter = journey.activeFilter
+
+ if (steps.length >= maxJourneySteps) {
+ setActiveLoading(false)
+ return
+ }
+
+ const journeyChanged =
+ prevStepsRef.current !== steps ||
+ prevDirectionRef.current !== currentDirection ||
+ prevDashboardStateRef.current !== dashboardState
+
+ prevStepsRef.current = steps
+ prevDirectionRef.current = currentDirection
+ prevDashboardStateRef.current = dashboardState
+
+ // Capture the version at effect-dispatch time so stale responses are
+ // discarded if the user mutates the journey before the response arrives.
+ const capturedVersion = journeyVersionRef.current
+ const isStale = () => journeyVersionRef.current !== capturedVersion
+
+ setActiveLoading(true)
+
+ const includeFunnel = journeyChanged && steps.length > 0
+
+ if (journeyChanged && steps.length === 0) {
+ setJourney(clearJourneyFunnel)
+ }
+
+ fetchNextWithFunnel(
+ site,
+ dashboardState,
+ steps,
+ activeFilter,
+ currentDirection,
+ includeFunnel
+ )
+ .then((response) => {
+ if (isStale()) return
+ setJourney((journey) =>
+ updateJourneyOnSuccess({
+ journey,
+ response,
+ includeFunnel,
+ journeyEndEvent
+ })
+ )
+ })
+ .catch((err) => {
+ if (isStale()) return
+ if (isRateLimitedError(err)) {
+ setJourney((journey) =>
+ updateJourneyOnRateLimitError({ journey, includeFunnel })
+ )
+ } else {
+ setJourney((journey) =>
+ updateJourneyOnError({ journey, includeFunnel })
+ )
+ }
+ })
+ .finally(() => {
+ if (!isStale()) setActiveLoading(false)
+ })
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ site,
+ dashboardState,
+ journey.steps,
+ journey.activeFilter,
+ inViewport,
+ retryCount,
+ directionKey
+ ])
+ // direction is intentionally excluded from the dep array. It lives in a ref
+ // and resets state, which does appear above, so the state update itself
+ // drives the re-run without double-firing.
+
+ const retry = useCallback(() => {
+ setJourney(clearJourneyRateLimit)
+ setRetryCount((c) => c + 1)
+ }, [])
+
+ return {
+ journey,
+ direction: directionRef.current,
+ activeLoading,
+ layoutKey,
+ rateLimited: journey.rateLimited,
+ selectStep,
+ reset,
+ retry,
+ setDirection,
+ setActiveFilter
+ }
+}
diff --git a/assets/js/dashboard/extra/exploration/helpers.ts b/assets/js/dashboard/extra/exploration/helpers.ts
new file mode 100644
index 000000000000..631aa4a945aa
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/helpers.ts
@@ -0,0 +1,6 @@
+export function roundedPercentage(value: number, total: number): number {
+ const percentage: number = (value / total) * 100
+ // Rounding to 2 decimal places using Math.round()
+ // (https://stackoverflow.com/a/11832950)
+ return Math.round((percentage + Number.EPSILON) * 100) / 100
+}
diff --git a/assets/js/dashboard/extra/exploration/index.tsx b/assets/js/dashboard/extra/exploration/index.tsx
new file mode 100644
index 000000000000..430f73a9334a
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/index.tsx
@@ -0,0 +1,246 @@
+import React, { useState, useEffect, useRef, RefObject } from 'react'
+import LazyLoader from '../../components/lazy-loader'
+import { Tooltip } from '../../util/tooltip'
+import { useSiteContext } from '../../site-context'
+import { useDashboardStateContext } from '../../dashboard-state-context'
+import {
+ numberShortFormatter,
+ percentageFormatter
+} from '../../util/number-formatter'
+import { RefreshIcon } from '../../components/icons'
+import { popover } from '../../components/popover'
+import { PathConnectors } from './path-connectors'
+import { ExplorationColumn, MaxDepthColumn } from './exploration-column'
+import { useExplorationData } from './exploration-state'
+import { DIRECTION, MIN_GRID_COLUMNS, ExplorationDirection } from './constants'
+
+// Column header label based on index and direction.
+function columnHeader(index: number, direction: ExplorationDirection): string {
+ if (index === 0) {
+ return direction === DIRECTION.BACKWARD ? 'End point' : 'Starting point'
+ }
+ const word = direction === DIRECTION.BACKWARD ? 'before' : 'after'
+ return `${index} step${index === 1 ? '' : 's'} ${word}`
+}
+
+// Scrolls the active column into view whenever the journey length changes.
+function useScrollActiveColumnIntoView(
+ containerRef: RefObject,
+ stepsLength: number
+) {
+ const prevLengthRef = useRef(0)
+
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el || stepsLength === prevLengthRef.current) {
+ prevLengthRef.current = stepsLength
+ return
+ }
+ prevLengthRef.current = stepsLength
+
+ const activeColumn = el.querySelector(
+ `[data-exploration-column="${stepsLength}"]`
+ )
+ if (activeColumn) {
+ const { left: colLeft, right: colRight } =
+ activeColumn.getBoundingClientRect()
+ const { left: containerLeft, right: containerRight } =
+ el.getBoundingClientRect()
+ if (colRight > containerRight || colLeft < containerLeft) {
+ el.scrollTo({
+ left: el.scrollLeft + colLeft - containerLeft,
+ behavior: 'smooth'
+ })
+ }
+ } else {
+ el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' })
+ }
+ }, [containerRef, stepsLength])
+}
+
+export function FunnelExploration() {
+ const site = useSiteContext()
+ const { dashboardState } = useDashboardStateContext()
+ const [inViewport, setInViewport] = useState(false)
+ const maxJourneySteps = site.explorationMaxJourneySteps
+
+ const {
+ journey,
+ direction,
+ activeLoading,
+ layoutKey,
+ rateLimited,
+ selectStep,
+ reset,
+ retry,
+ setDirection,
+ setActiveFilter
+ } = useExplorationData({ site, dashboardState, inViewport })
+
+ const { steps, funnel, activeResults, activeFilter, frozen, provisional } =
+ journey
+
+ const containerRef = useRef(null)
+ useScrollActiveColumnIntoView(containerRef, steps.length)
+
+ const initialLoading = !inViewport || (steps.length === 0 && activeLoading)
+ const journeyEnded =
+ !activeLoading && activeResults.length === 0 && steps.length >= 1
+ const activeColumnIndex = steps.length
+
+ const numColumns = initialLoading
+ ? 1
+ : journeyEnded || (activeLoading && steps.length === 1)
+ ? steps.length + 1
+ : Math.max(steps.length + 1, MIN_GRID_COLUMNS)
+
+ const gridColumns = Math.max(numColumns, MIN_GRID_COLUMNS)
+
+ const noData =
+ !initialLoading &&
+ !activeLoading &&
+ steps.length === 0 &&
+ funnel.length === 0 &&
+ activeResults.length === 0 &&
+ !activeFilter &&
+ !rateLimited
+
+ const lastFunnelStep = funnel.length >= 2 ? funnel[funnel.length - 1] : null
+ const overallConversionRate = lastFunnelStep?.conversion_rate ?? null
+ const overallConversionVisitors = lastFunnelStep?.visitors ?? null
+
+ return (
+ setInViewport(true)}>
+
+
+
+ {funnel.length >= 2
+ ? `${funnel.length}-step user journey`
+ : 'Explore user journeys'}
+
+
+ {overallConversionRate != null && (
+
+
+
+ CR: {percentageFormatter(parseFloat(overallConversionRate!))}{' '}
+
+
+ ({numberShortFormatter(overallConversionVisitors!)})
+
+
+
+ |
+
+
+ )}
+
+
Deselect all}
+ className={
+ steps.length === 0 ? 'invisible pointer-events-none' : ''
+ }
+ >
+
+
+
+
+ {noData ? (
+
+ No data yet
+
+ ) : (
+
+ {Array.from({ length: numColumns }, (_, i) => {
+ const isActive = i === activeColumnIndex
+ const isReachable = steps.length >= i
+
+ const colFilter = isActive ? activeFilter : ''
+ const colFrozen = frozen[i] ?? []
+
+ const colResults =
+ isActive && (activeResults.length > 0 || colFilter)
+ ? activeResults
+ : colFrozen
+ const colLoadingInBackground =
+ isActive && (initialLoading || activeLoading)
+ const colLoading =
+ colLoadingInBackground && (!frozen[i] || !!colFilter)
+
+ const colSelectedVisitors =
+ provisional[i]?.visitors ?? funnel[i]?.visitors ?? null
+ const colSelectedConversionRate =
+ provisional[i]?.conversion_rate ??
+ funnel[i]?.conversion_rate ??
+ null
+
+ const colHeaderConversionRate =
+ funnel[i]?.conversion_rate != null
+ ? i === 0
+ ? '100%'
+ : `${parseFloat(funnel[i].conversion_rate).toFixed(1)}%`
+ : null
+
+ if (isActive && steps.length >= maxJourneySteps) {
+ return (
+
+ )
+ }
+
+ return (
+
{}}
+ onSelect={(step) => selectStep(i, step)}
+ rateLimited={isActive && rateLimited}
+ onRetry={retry}
+ />
+ )
+ })}
+
+
+
+ )}
+
+
+ )
+}
+
+export default FunnelExploration
diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts
new file mode 100644
index 000000000000..e8707b464d39
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/journey.ts
@@ -0,0 +1,304 @@
+import { roundedPercentage } from './helpers'
+
+export type JourneyStep = {
+ label: string
+ name: string
+ pathname: string
+ includes_subpaths: boolean
+ subpaths_count: number
+ is_goal: boolean
+}
+
+export type JourneySuggestion = {
+ step: JourneyStep
+ visitors: number
+}
+
+export type FunnelStep = {
+ step: JourneyStep
+ visitors: number
+ dropoff: number
+ dropoff_percentage: number
+ conversion_rate: string
+ conversion_rate_step: string
+}
+
+type ProvisionalFunnelStep = {
+ visitors: number
+ conversion_rate: number
+}
+
+type FrozenSuggestions = { [columnIndex: string]: JourneySuggestion[] }
+type ProvisionalFunnelSteps = { [columnIndex: string]: ProvisionalFunnelStep }
+
+export type Journey = {
+ steps: JourneyStep[]
+ funnel: FunnelStep[]
+ activeResults: JourneySuggestion[]
+ activeFilter: string
+ // list of suggestions the user saw when picking step
+ frozen: FrozenSuggestions
+ provisional: ProvisionalFunnelSteps
+ rateLimited: boolean
+}
+
+type JourneyResponse = {
+ next: JourneySuggestion[] | null
+ funnel: FunnelStep[] | null
+} | null
+
+// Keep only entries with index < fromIndex, discarding everything at or after.
+// Used to truncate frozen candidate snapshots when the journey is shortened.
+function truncateFrozenAt(
+ frozen: FrozenSuggestions,
+ fromIndex: number
+): FrozenSuggestions {
+ const result: FrozenSuggestions = {}
+ for (const key of Object.keys(frozen)) {
+ if (Number(key) < fromIndex) result[key] = frozen[key]
+ }
+ return result
+}
+
+// Compute provisional funnel entries for a newly selected step so the UI
+// displays sensible values immediately before the API responds.
+function provisionalEntry(
+ step: JourneyStep,
+ columnIndex: number,
+ sourceResults: JourneySuggestion[],
+ existingFunnel: FunnelStep[]
+): ProvisionalFunnelSteps {
+ const match = sourceResults.find(({ step: s }: JourneySuggestion): boolean =>
+ journeyStepsEqual(s, step)
+ )
+ if (!match) return {}
+
+ const firstStepVisitors = existingFunnel[0]?.visitors ?? match.visitors
+ const conversionRate = roundedPercentage(match.visitors, firstStepVisitors)
+ return {
+ [columnIndex]: { visitors: match.visitors, conversion_rate: conversionRate }
+ }
+}
+
+function deselectStep(journey: Journey, columnIndex: number): Journey {
+ // Deselect: truncate journey at columnIndex.
+ return {
+ ...journey,
+ steps: journey.steps.slice(0, columnIndex),
+ activeResults: [],
+ activeFilter: '',
+ frozen: truncateFrozenAt(journey.frozen, columnIndex + 1),
+ provisional: {},
+ rateLimited: false
+ }
+}
+
+function selectStep(
+ journey: Journey,
+ columnIndex: number,
+ newStep: JourneyStep
+): Journey {
+ // Select: determine source results for provisional values.
+ const sourceResults =
+ columnIndex === journey.steps.length
+ ? journey.activeResults
+ : (journey.frozen[columnIndex] ?? [])
+
+ const newFrozen =
+ columnIndex === journey.steps.length
+ ? {
+ ...truncateFrozenAt(journey.frozen, columnIndex),
+ [columnIndex]: journey.activeResults
+ }
+ : truncateFrozenAt(journey.frozen, columnIndex + 1)
+
+ return {
+ ...journey,
+ steps: [...journey.steps.slice(0, columnIndex), newStep],
+ activeResults: [],
+ activeFilter: '',
+ frozen: newFrozen,
+ provisional: provisionalEntry(
+ newStep,
+ columnIndex,
+ sourceResults,
+ journey.funnel
+ ),
+ rateLimited: false
+ }
+}
+
+function maybeEmptyResults(
+ results: JourneySuggestion[],
+ activeFilter: string,
+ journeyEndEvent: string
+): JourneySuggestion[] {
+ if (
+ results.length === 0 ||
+ (!activeFilter &&
+ results.length === 1 &&
+ results[0].step.name === journeyEndEvent)
+ ) {
+ return []
+ } else {
+ return results
+ }
+}
+
+// Two steps are identical when their identity fields match.
+export function journeyStepsEqual(a: JourneyStep, b: JourneyStep): boolean {
+ return (
+ a.name === b.name &&
+ a.pathname === b.pathname &&
+ a.includes_subpaths === b.includes_subpaths
+ )
+}
+
+export function emptyJourney(): Journey {
+ return {
+ steps: [],
+ funnel: [],
+ activeResults: [],
+ activeFilter: '',
+ // list of suggestions the user saw when picking step
+ frozen: {},
+ provisional: {},
+ rateLimited: false
+ }
+}
+
+export function toggleJourneyStep({
+ journey,
+ columnIndex,
+ newStep
+}: {
+ journey: Journey
+ columnIndex: number
+ newStep: JourneyStep | null
+}): Journey {
+ if (newStep === null) {
+ return deselectStep(journey, columnIndex)
+ }
+
+ return selectStep(journey, columnIndex, newStep)
+}
+
+export function setJourneyActiveFilter({
+ journey,
+ filter
+}: {
+ journey: Journey
+ filter: string
+}): Journey {
+ return { ...journey, activeFilter: filter }
+}
+
+export function clearJourneyFrozen(journey: Journey): Journey {
+ return { ...journey, frozen: {} }
+}
+
+export function clearJourneyFunnel(journey: Journey): Journey {
+ return { ...journey, funnel: [] }
+}
+
+export function clearJourneyRateLimit(journey: Journey): Journey {
+ return { ...journey, rateLimited: false }
+}
+
+export function updateJourneyOnSuccess({
+ journey,
+ response,
+ includeFunnel,
+ journeyEndEvent
+}: {
+ journey: Journey
+ response: JourneyResponse
+ includeFunnel: boolean
+ journeyEndEvent: string
+}): Journey {
+ const newJourney = {
+ ...journey,
+ activeResults: maybeEmptyResults(
+ response?.next ?? [],
+ journey.activeFilter,
+ journeyEndEvent
+ ),
+ rateLimited: false
+ }
+
+ if (includeFunnel) {
+ let newFunnel = response?.funnel ?? []
+ newJourney.provisional = {}
+
+ // Truncate the funnel at first 0-visitors step.
+ // This happens when the dashboard state narrows (e.g. shorter time range)
+ // and the existing steps can no longer be fulfilled.
+ const firstZeroIdx = newFunnel.findIndex(
+ (f: FunnelStep): boolean => f.visitors === 0
+ )
+
+ if (firstZeroIdx !== -1) {
+ newFunnel = newFunnel.slice(0, firstZeroIdx)
+ newJourney.steps = journey.steps.slice(0, firstZeroIdx)
+ newJourney.frozen = truncateFrozenAt(journey.frozen, firstZeroIdx)
+ newJourney.activeResults = []
+ }
+
+ newJourney.funnel = newFunnel
+
+ // Sync subpaths_count on existing steps from the refreshed funnel
+ // so that step identity stays consistent with what the API now
+ // reports for the current period. Without this, a period change
+ // leaves stale subpaths_count values in steps while frozen
+ // candidates and new results carry fresh values, causing duplicate
+ // entries and double-highlighted rows.
+ const currentSteps = newJourney.steps ?? journey.steps
+ if (newFunnel.length > 0 && currentSteps.length > 0) {
+ const synced = currentSteps.map(
+ (s: JourneyStep, idx: number): JourneyStep =>
+ newFunnel[idx]
+ ? { ...s, subpaths_count: newFunnel[idx].step.subpaths_count }
+ : s
+ )
+ // Only replace the steps reference when something actually changed
+ // to avoid re-triggering the main effect (steps is a dep array entry).
+ const changed = synced.some(
+ (s: JourneyStep, idx: number): boolean =>
+ s.subpaths_count !== currentSteps[idx].subpaths_count
+ )
+ if (changed) newJourney.steps = synced
+ }
+ }
+ return newJourney
+}
+
+export function updateJourneyOnRateLimitError({
+ journey,
+ includeFunnel
+}: {
+ journey: Journey
+ includeFunnel: boolean
+}): Journey {
+ return {
+ ...journey,
+ frozen: truncateFrozenAt(journey.frozen, journey.steps.length),
+ rateLimited: true,
+ activeResults: [],
+ ...(includeFunnel ? { provisional: {} } : {})
+ }
+}
+
+export function updateJourneyOnError({
+ journey,
+ includeFunnel
+}: {
+ journey: Journey
+ includeFunnel: boolean
+}): Journey {
+ return {
+ ...journey,
+ frozen: truncateFrozenAt(journey.frozen, journey.steps.length),
+ activeResults: [],
+ ...(includeFunnel ? { funnel: [] } : {})
+ }
+}
diff --git a/assets/js/dashboard/extra/exploration/path-connectors.tsx b/assets/js/dashboard/extra/exploration/path-connectors.tsx
new file mode 100644
index 000000000000..6d053b230d64
--- /dev/null
+++ b/assets/js/dashboard/extra/exploration/path-connectors.tsx
@@ -0,0 +1,188 @@
+import React, {
+ useLayoutEffect,
+ useCallback,
+ useState,
+ ReactNode,
+ RefObject
+} from 'react'
+import { JourneyStep } from './journey'
+
+type ClipRect = {
+ y: number
+ height: number
+}
+
+type SVGData = {
+ paths: string[]
+ width: number
+ height: number
+ clipY: number
+ clipHeight: number
+}
+
+function emptySVGData(): SVGData {
+ return {
+ paths: [],
+ width: 0,
+ height: 0,
+ clipY: 0,
+ clipHeight: 0
+ }
+}
+
+// x-coordinate of a column element's left or right edge in the coordinate
+// space of the scroll container, stable across horizontal scrolling.
+function columnEdgeX(
+ colEl: Element,
+ side: 'left' | 'right',
+ containerRect: DOMRect,
+ scrollLeft: number
+): number {
+ const rect = colEl.getBoundingClientRect()
+ const edgeX = side === 'right' ? rect.right : rect.left
+ return edgeX - containerRect.left + scrollLeft
+}
+
+// Vertical midpoint of a step row relative to the top of the container.
+function stepRowMidY(stepRowEl: Element, containerRect: DOMRect): number {
+ const rect = stepRowEl.getBoundingClientRect()
+ return (rect.top + rect.bottom) / 2 - containerRect.top
+}
+
+// SVG path for a stepped connector with rounded corners.
+function steppedPath(x1: number, y1: number, x2: number, y2: number): string {
+ const mx = (x1 + x2) / 2
+ const dy = y2 - y1
+
+ if (Math.abs(dy) < 1) {
+ return `M ${x1} ${y1} H ${x2}`
+ }
+
+ const r = Math.min(10, Math.abs(dy) / 2)
+
+ if (dy > 0) {
+ return `M ${x1} ${y1} H ${mx - r} A ${r} ${r} 0 0 1 ${mx} ${y1 + r} V ${y2 - r} A ${r} ${r} 0 0 0 ${mx + r} ${y2} H ${x2}`
+ } else {
+ return `M ${x1} ${y1} H ${mx - r} A ${r} ${r} 0 0 0 ${mx} ${y1 - r} V ${y2 + r} A ${r} ${r} 0 0 1 ${mx + r} ${y2} H ${x2}`
+ }
+}
+
+// Clip rect that keeps connectors inside the list area,
+// preventing them from bleeding into column headers.
+function listClipRect(container: Element, containerRect: DOMRect): ClipRect {
+ const firstList = container.querySelector('[data-exploration-list]')
+ if (!firstList) return { y: 0, height: container.clientHeight }
+ const rect = firstList.getBoundingClientRect()
+ return { y: rect.top - containerRect.top, height: rect.height }
+}
+
+function computeConnectors(container: Element, steps: JourneyStep[]): SVGData {
+ const containerRect = container.getBoundingClientRect()
+ const paths = []
+
+ for (let i = 0; i < steps.length - 1; i++) {
+ // Query by explicit column index so DOM order never causes a mismatch.
+ const colA = container.querySelector(`[data-exploration-column="${i}"]`)
+ const colB = container.querySelector(`[data-exploration-column="${i + 1}"]`)
+ const rowA = container.querySelector(`[data-exploration-step="${i}"]`)
+ const rowB = container.querySelector(`[data-exploration-step="${i + 1}"]`)
+
+ if (colA && colB && rowA && rowB) {
+ const x1 = columnEdgeX(colA, 'right', containerRect, container.scrollLeft)
+ const x2 = columnEdgeX(colB, 'left', containerRect, container.scrollLeft)
+ const y1 = stepRowMidY(rowA, containerRect)
+ const y2 = stepRowMidY(rowB, containerRect)
+ paths.push(steppedPath(x1, y1, x2, y2))
+ }
+ }
+
+ const clip = listClipRect(container, containerRect)
+
+ return {
+ paths,
+ width: container.scrollWidth,
+ height: container.clientHeight,
+ clipY: clip.y,
+ clipHeight: clip.height
+ }
+}
+
+// layoutKey is bumped whenever the DOM may have changed in a way that is not
+// reflected by a steps reference change, e.g. a dashboardState update. It
+// is the caller's responsibility to increment it after such changes.
+export function PathConnectors({
+ steps,
+ containerRef,
+ layoutKey
+}: {
+ steps: JourneyStep[]
+ containerRef: RefObject
+ layoutKey: number
+}): ReactNode | null {
+ const [svgData, setSvgData] = useState(emptySVGData)
+
+ const recalculate = useCallback(() => {
+ const container = containerRef.current
+ if (container) setSvgData(computeConnectors(container, steps))
+ }, [steps, containerRef])
+
+ useLayoutEffect(() => {
+ const container = containerRef.current
+
+ if (!container || steps.length < 2) {
+ setSvgData(emptySVGData)
+ return
+ }
+
+ setSvgData(computeConnectors(container, steps))
+
+ const observer = new ResizeObserver(recalculate)
+ observer.observe(container)
+ window.addEventListener('resize', recalculate)
+
+ const lists: Element[] = Array.from(
+ container.querySelectorAll('[data-exploration-list]')
+ )
+ lists.forEach((list) => list.addEventListener('scroll', recalculate))
+
+ return () => {
+ observer.disconnect()
+ window.removeEventListener('resize', recalculate)
+ lists.forEach((list) => list.removeEventListener('scroll', recalculate))
+ }
+ // layoutKey is intentionally included: it forces this effect to re-run
+ // and recalculate geometry after DOM updates that don't change steps.
+ }, [steps, containerRef, recalculate, layoutKey])
+
+ if (svgData.paths.length === 0) return null
+
+ return (
+
+ )
+}