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 ( - - - - - - - {svgData.paths.map((d, i) => ( - - ))} - - ) -} - -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 ( + + + + + + + {svgData.paths.map( + (d: string, i: number): ReactNode => ( + + ) + )} + + ) +}