From 15daca05a5f163cf9b707c9bb567710f9bd7f6d9 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 11:30:18 +0200 Subject: [PATCH 01/21] WIP --- assets/js/dashboard/extra/exploration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index a2ee5a9e8a63..1022338d3f58 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -704,7 +704,7 @@ function useExplorationData(site, dashboardState, inViewport) { const directionRef = useRef(DIRECTION.FORWARD) const selectStep = useCallback((columnIndex, step) => { - ++journeyVersionRef.current + journeyVersionRef.current++ setState((prev) => { if (step === null) { From 901d94dc135510402356d8e4c5727b3fbb4f18ec Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 12:17:24 +0200 Subject: [PATCH 02/21] Rename state/setState to journey/setJourney for clarity --- assets/js/dashboard/extra/exploration.js | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 1022338d3f58..215d01b9aa0d 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -678,7 +678,7 @@ function useExplorationData(site, dashboardState, inViewport) { explorationMaxJourneySteps: maxJourneySteps, explorationJourneyEndEvent: journeyEndEvent } = useSiteContext() - const [state, setState] = useState(EMPTY_JOURNEY_STATE) + const [journey, setJourney] = useState(EMPTY_JOURNEY_STATE) const [activeLoading, setActiveLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) const [directionKey, setDirectionKey] = useState(0) @@ -690,7 +690,7 @@ function useExplorationData(site, dashboardState, inViewport) { // 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 prevStepsRef = useRef(journey.steps) const prevDirectionRef = useRef(DIRECTION.FORWARD) const prevDashboardStateRef = useRef(dashboardState) @@ -706,7 +706,7 @@ function useExplorationData(site, dashboardState, inViewport) { const selectStep = useCallback((columnIndex, step) => { journeyVersionRef.current++ - setState((prev) => { + setJourney((prev) => { if (step === null) { // Deselect: truncate journey at columnIndex. return { @@ -754,19 +754,19 @@ function useExplorationData(site, dashboardState, inViewport) { const reset = useCallback(() => { ++journeyVersionRef.current setActiveLoading(true) - setState(EMPTY_JOURNEY_STATE) + setJourney(EMPTY_JOURNEY_STATE) }, []) const setDirection = useCallback((newDirection) => { if (newDirection === directionRef.current) return directionRef.current = newDirection ++journeyVersionRef.current - setState(EMPTY_JOURNEY_STATE) + setJourney(EMPTY_JOURNEY_STATE) setDirectionKey((k) => k + 1) }, []) const setActiveFilter = useCallback((filter) => { - setState((prev) => ({ ...prev, activeFilter: filter })) + setJourney((prev) => ({ ...prev, activeFilter: filter })) }, []) // Frozen candidate lists were fetched against a specific site and dashboard @@ -781,7 +781,7 @@ function useExplorationData(site, dashboardState, inViewport) { return } ++journeyVersionRef.current - setState((prev) => ({ ...prev, frozen: {} })) + setJourney((prev) => ({ ...prev, frozen: {} })) setLayoutKey((k) => k + 1) }, [site, dashboardState]) @@ -789,8 +789,8 @@ function useExplorationData(site, dashboardState, inViewport) { if (!inViewport) return const currentDirection = directionRef.current - const steps = state.steps - const activeFilter = state.activeFilter + const steps = journey.steps + const activeFilter = journey.activeFilter if (steps.length >= maxJourneySteps) { setActiveLoading(false) @@ -816,7 +816,7 @@ function useExplorationData(site, dashboardState, inViewport) { const includeFunnel = journeyChanged && steps.length > 0 if (journeyChanged && steps.length === 0) { - setState((prev) => ({ ...prev, funnel: [] })) + setJourney((prev) => ({ ...prev, funnel: [] })) } fetchNextWithFunnel( @@ -829,7 +829,7 @@ function useExplorationData(site, dashboardState, inViewport) { ) .then((response) => { if (isStale()) return - setState((prev) => { + setJourney((prev) => { const next = { ...prev, activeResults: maybeEmptyResults( @@ -884,7 +884,7 @@ function useExplorationData(site, dashboardState, inViewport) { .catch((err) => { if (isStale()) return if (isRateLimitedError(err)) { - setState((prev) => ({ + setJourney((prev) => ({ ...prev, frozen: truncateFrozenAt(prev.frozen, prev.steps.length), rateLimited: true, @@ -892,7 +892,7 @@ function useExplorationData(site, dashboardState, inViewport) { ...(includeFunnel ? { provisional: {} } : {}) })) } else { - setState((prev) => ({ + setJourney((prev) => ({ ...prev, frozen: truncateFrozenAt(prev.frozen, prev.steps.length), activeResults: [], @@ -906,8 +906,8 @@ function useExplorationData(site, dashboardState, inViewport) { }, [ site, dashboardState, - state.steps, - state.activeFilter, + journey.steps, + journey.activeFilter, inViewport, retryCount, directionKey @@ -917,16 +917,16 @@ function useExplorationData(site, dashboardState, inViewport) { // drives the re-run without double-firing. const retry = useCallback(() => { - setState((prev) => ({ ...prev, rateLimited: false })) + setJourney((prev) => ({ ...prev, rateLimited: false })) setRetryCount((c) => c + 1) }, []) return { - state, + journey, direction: directionRef.current, activeLoading, layoutKey, - rateLimited: state.rateLimited, + rateLimited: journey.rateLimited, selectStep, reset, retry, @@ -974,7 +974,7 @@ export function FunnelExploration() { const maxJourneySteps = site.explorationMaxJourneySteps const { - state, + journey, direction, activeLoading, layoutKey, @@ -987,7 +987,7 @@ export function FunnelExploration() { } = useExplorationData(site, dashboardState, inViewport) const { steps, funnel, activeResults, activeFilter, frozen, provisional } = - state + journey const containerRef = useRef(null) useScrollActiveColumnIntoView(containerRef, steps.length) From 56cbc689bdfb07a6eb53dc6a363a51151763b5d9 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 12:33:06 +0200 Subject: [PATCH 03/21] Move exploration module under a module/submodule structure --- .../{exploration.js => exploration/index.js} | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) rename assets/js/dashboard/extra/{exploration.js => exploration/index.js} (98%) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration/index.js similarity index 98% rename from assets/js/dashboard/extra/exploration.js rename to assets/js/dashboard/extra/exploration/index.js index 215d01b9aa0d..cbc6b63cbd49 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -5,23 +5,23 @@ import React, { 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 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' +} 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' +import { popover } from '../../components/popover' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } From d74723ea78b8698d514c3efe00265e5b03882b78 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 12:52:11 +0200 Subject: [PATCH 04/21] Extract step toggle logic --- .../js/dashboard/extra/exploration/index.js | 46 +--------- .../js/dashboard/extra/exploration/journey.ts | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 assets/js/dashboard/extra/exploration/journey.ts diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index cbc6b63cbd49..79ee1c0b0703 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -22,6 +22,7 @@ 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' +import { toggleStep } from './journey' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -705,50 +706,7 @@ function useExplorationData(site, dashboardState, inViewport) { const selectStep = useCallback((columnIndex, step) => { journeyVersionRef.current++ - - setJourney((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 - } - }) + setJourney((journey) => toggleStep({ journey, columnIndex, newStep: step })) }, []) const reset = useCallback(() => { diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts new file mode 100644 index 000000000000..529ee98560a1 --- /dev/null +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -0,0 +1,90 @@ +// 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 + ) +} + +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 +} + +// 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 +} + +// 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 } + } +} + +function deselectStep(journey, columnIndex) { + // 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, columnIndex, newStep) { + // 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 + } +} + +export function toggleStep({ journey, columnIndex, newStep }) { + if (newStep === null) { + return deselectStep(journey, columnIndex) + } + + return selectStep(journey, columnIndex, newStep) +} From f92a48acdb8e9cb28de494a1e0ea11d9f8fb07d5 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 12:55:58 +0200 Subject: [PATCH 05/21] Extract empty journey --- .../js/dashboard/extra/exploration/index.js | 19 ++++--------------- .../js/dashboard/extra/exploration/journey.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index 79ee1c0b0703..ddf3af843f33 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -22,7 +22,7 @@ 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' -import { toggleStep } from './journey' +import { emptyJourney, toggleStep } from './journey' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -36,17 +36,6 @@ 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, @@ -679,7 +668,7 @@ function useExplorationData(site, dashboardState, inViewport) { explorationMaxJourneySteps: maxJourneySteps, explorationJourneyEndEvent: journeyEndEvent } = useSiteContext() - const [journey, setJourney] = useState(EMPTY_JOURNEY_STATE) + const [journey, setJourney] = useState(emptyJourney) const [activeLoading, setActiveLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) const [directionKey, setDirectionKey] = useState(0) @@ -712,14 +701,14 @@ function useExplorationData(site, dashboardState, inViewport) { const reset = useCallback(() => { ++journeyVersionRef.current setActiveLoading(true) - setJourney(EMPTY_JOURNEY_STATE) + setJourney(emptyJourney) }, []) const setDirection = useCallback((newDirection) => { if (newDirection === directionRef.current) return directionRef.current = newDirection ++journeyVersionRef.current - setJourney(EMPTY_JOURNEY_STATE) + setJourney(emptyJourney) setDirectionKey((k) => k + 1) }, []) diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 529ee98560a1..1e0a217b7b08 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -81,6 +81,19 @@ function selectStep(journey, columnIndex, newStep) { } } +export function emptyJourney() { + return { + steps: [], + funnel: [], + activeResults: [], + activeFilter: '', + // list of suggestions the user saw when picking step + frozen: {}, + provisional: {}, + rateLimited: false + } +} + export function toggleStep({ journey, columnIndex, newStep }) { if (newStep === null) { return deselectStep(journey, columnIndex) From 5020c63baae74b8f3e7f123827b48805a628d24d Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 12:58:44 +0200 Subject: [PATCH 06/21] Rename toggleStep to toggleJourneyStep --- assets/js/dashboard/extra/exploration/index.js | 4 ++-- assets/js/dashboard/extra/exploration/journey.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index ddf3af843f33..6ec3e95c575c 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -22,7 +22,7 @@ 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' -import { emptyJourney, toggleStep } from './journey' +import { emptyJourney, toggleJourneyStep } from './journey' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -695,7 +695,7 @@ function useExplorationData(site, dashboardState, inViewport) { const selectStep = useCallback((columnIndex, step) => { journeyVersionRef.current++ - setJourney((journey) => toggleStep({ journey, columnIndex, newStep: step })) + setJourney((journey) => toggleJourneyStep({ journey, columnIndex, newStep: step })) }, []) const reset = useCallback(() => { diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 1e0a217b7b08..920edc642c69 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -94,7 +94,7 @@ export function emptyJourney() { } } -export function toggleStep({ journey, columnIndex, newStep }) { +export function toggleJourneyStep({ journey, columnIndex, newStep }) { if (newStep === null) { return deselectStep(journey, columnIndex) } From 1b7fe5110da8fd34e5e8018dab0bbce48bc9ecb7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 13:37:21 +0200 Subject: [PATCH 07/21] Extract journey update logic and smaller state update helpers --- .../js/dashboard/extra/exploration/index.js | 102 ++++-------------- .../js/dashboard/extra/exploration/journey.ts | 81 ++++++++++++++ 2 files changed, 100 insertions(+), 83 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index 6ec3e95c575c..dbe7222f93d6 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -22,7 +22,15 @@ 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' -import { emptyJourney, toggleJourneyStep } from './journey' +import { + emptyJourney, + toggleJourneyStep, + setJourneyActiveFilter, + clearJourneyFrozen, + clearJourneyFunnel, + clearJourneyRateLimit, + updateJourney +} from './journey' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -91,19 +99,6 @@ function stepsToJourneyParam(steps) { ) } -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) { @@ -648,19 +643,6 @@ function ExplorationColumn({ ) } -// 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) { @@ -695,7 +677,9 @@ function useExplorationData(site, dashboardState, inViewport) { const selectStep = useCallback((columnIndex, step) => { journeyVersionRef.current++ - setJourney((journey) => toggleJourneyStep({ journey, columnIndex, newStep: step })) + setJourney((journey) => + toggleJourneyStep({ journey, columnIndex, newStep: step }) + ) }, []) const reset = useCallback(() => { @@ -713,7 +697,7 @@ function useExplorationData(site, dashboardState, inViewport) { }, []) const setActiveFilter = useCallback((filter) => { - setJourney((prev) => ({ ...prev, activeFilter: filter })) + setJourney((journey) => setJourneyActiveFilter({ journey, filter })) }, []) // Frozen candidate lists were fetched against a specific site and dashboard @@ -728,7 +712,7 @@ function useExplorationData(site, dashboardState, inViewport) { return } ++journeyVersionRef.current - setJourney((prev) => ({ ...prev, frozen: {} })) + setJourney(clearJourneyFrozen) setLayoutKey((k) => k + 1) }, [site, dashboardState]) @@ -763,7 +747,7 @@ function useExplorationData(site, dashboardState, inViewport) { const includeFunnel = journeyChanged && steps.length > 0 if (journeyChanged && steps.length === 0) { - setJourney((prev) => ({ ...prev, funnel: [] })) + setJourney(clearJourneyFunnel) } fetchNextWithFunnel( @@ -776,57 +760,9 @@ function useExplorationData(site, dashboardState, inViewport) { ) .then((response) => { if (isStale()) return - setJourney((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 - }) + setJourney((journey) => + updateJourney({ journey, response, includeFunnel, journeyEndEvent }) + ) }) .catch((err) => { if (isStale()) return @@ -864,7 +800,7 @@ function useExplorationData(site, dashboardState, inViewport) { // drives the re-run without double-firing. const retry = useCallback(() => { - setJourney((prev) => ({ ...prev, rateLimited: false })) + setJourney(clearJourneyRateLimit) setRetryCount((c) => c + 1) }, []) diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 920edc642c69..45c8414643cd 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -81,6 +81,19 @@ function selectStep(journey, columnIndex, newStep) { } } +function maybeEmptyResults(results, activeFilter, journeyEndEvent) { + if ( + results.length === 0 || + (!activeFilter && + results.length === 1 && + results[0].step.name === journeyEndEvent) + ) { + return [] + } else { + return results + } +} + export function emptyJourney() { return { steps: [], @@ -101,3 +114,71 @@ export function toggleJourneyStep({ journey, columnIndex, newStep }) { return selectStep(journey, columnIndex, newStep) } + +export function setJourneyActiveFilter({ journey, filter }) { + return { ...journey, activeFilter: filter } +} + +export function clearJourneyFrozen(journey) { + return { ...journey, frozen: {} } +} + +export function clearJourneyFunnel(journey) { + return { ...journey, funnel: [] } +} + +export function clearJourneyRateLimit(journey) { + return { ...journey, rateLimited: false } +} + +export function updateJourney({ journey, response, includeFunnel, journeyEndEvent }) { + 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) => 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, 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) newJourney.steps = synced + } + } + return newJourney +} From d743e92d8e790254b1cf3fae4ab223362e7761a7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 13:49:54 +0200 Subject: [PATCH 08/21] Extract journey update error handling logic --- .../js/dashboard/extra/exploration/index.js | 40 +++++++------------ .../js/dashboard/extra/exploration/journey.ts | 26 +++++++++++- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index dbe7222f93d6..007f58414f6f 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -29,7 +29,9 @@ import { clearJourneyFrozen, clearJourneyFunnel, clearJourneyRateLimit, - updateJourney + updateJourneyOnSuccess, + updateJourneyOnError, + updateJourneyOnRateLimitError } from './journey' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -99,16 +101,6 @@ function stepsToJourneyParam(steps) { ) } -// 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) { @@ -761,26 +753,24 @@ function useExplorationData(site, dashboardState, inViewport) { .then((response) => { if (isStale()) return setJourney((journey) => - updateJourney({ journey, response, includeFunnel, journeyEndEvent }) + updateJourneyOnSuccess({ + journey, + response, + includeFunnel, + journeyEndEvent + }) ) }) .catch((err) => { if (isStale()) return if (isRateLimitedError(err)) { - setJourney((prev) => ({ - ...prev, - frozen: truncateFrozenAt(prev.frozen, prev.steps.length), - rateLimited: true, - activeResults: [], - ...(includeFunnel ? { provisional: {} } : {}) - })) + setJourney((journey) => + updateJourneyOnRateLimitError({ journey, includeFunnel }) + ) } else { - setJourney((prev) => ({ - ...prev, - frozen: truncateFrozenAt(prev.frozen, prev.steps.length), - activeResults: [], - ...(includeFunnel ? { funnel: [] } : {}) - })) + setJourney((journey) => + updateJourneyOnError({ journey, includeFunnel }) + ) } }) .finally(() => { diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 45c8414643cd..6f90c78a3b46 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -131,7 +131,12 @@ export function clearJourneyRateLimit(journey) { return { ...journey, rateLimited: false } } -export function updateJourney({ journey, response, includeFunnel, journeyEndEvent }) { +export function updateJourneyOnSuccess({ + journey, + response, + includeFunnel, + journeyEndEvent +}) { const newJourney = { ...journey, activeResults: maybeEmptyResults( @@ -182,3 +187,22 @@ export function updateJourney({ journey, response, includeFunnel, journeyEndEven } return newJourney } + +export function updateJourneyOnRateLimitError({ journey, includeFunnel }) { + return { + ...journey, + frozen: truncateFrozenAt(journey.frozen, journey.steps.length), + rateLimited: true, + activeResults: [], + ...(includeFunnel ? { provisional: {} } : {}) + } +} + +export function updateJourneyOnError({ journey, includeFunnel }) { + return { + ...journey, + frozen: truncateFrozenAt(journey.frozen, journey.steps.length), + activeResults: [], + ...(includeFunnel ? { funnel: [] } : {}) + } +} From 518435bfa0e1458cc54369ab968ec9b3a2aa5144 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 13:56:49 +0200 Subject: [PATCH 09/21] Extract other common stateless logic --- .../js/dashboard/extra/exploration/helpers.ts | 7 +++++ .../js/dashboard/extra/exploration/index.js | 24 ++++------------- .../js/dashboard/extra/exploration/journey.ts | 27 ++++++++----------- 3 files changed, 23 insertions(+), 35 deletions(-) create mode 100644 assets/js/dashboard/extra/exploration/helpers.ts diff --git a/assets/js/dashboard/extra/exploration/helpers.ts b/assets/js/dashboard/extra/exploration/helpers.ts new file mode 100644 index 000000000000..6852b1b5544a --- /dev/null +++ b/assets/js/dashboard/extra/exploration/helpers.ts @@ -0,0 +1,7 @@ +export 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 +} + diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index 007f58414f6f..c1807c7bb8b4 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -31,8 +31,10 @@ import { clearJourneyRateLimit, updateJourneyOnSuccess, updateJourneyOnError, - updateJourneyOnRateLimitError + updateJourneyOnRateLimitError, + journeyStepsEqual } from './journey' +import { roundedPercentage } from './helpers' const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } @@ -54,26 +56,10 @@ const EMPTY_SVG_DATA = { 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) { @@ -620,8 +606,8 @@ function ExplorationColumn({ key={`${step.name}:${step.label}:${step.includes_subpaths ? step.subpaths_count : 0}`} step={step} visitors={visitors} - isSelected={!!selected && stepsEqual(step, selected)} - isDimmed={!!selected && !stepsEqual(step, selected)} + isSelected={!!selected && journeyStepsEqual(step, selected)} + isDimmed={!!selected && !journeyStepsEqual(step, selected)} selectedVisitors={selectedVisitors} selectedConversionRate={selectedConversionRate} stepMaxVisitors={stepMaxVisitors} diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 6f90c78a3b46..14c203daf10b 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -1,18 +1,4 @@ -// 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 - ) -} - -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 -} +import { roundedPercentage } from './helpers' // Keep only entries with index < fromIndex, discarding everything at or after. // Used to truncate frozen candidate snapshots when the journey is shortened. @@ -27,7 +13,7 @@ function truncateFrozenAt(frozen, fromIndex) { // 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)) + const match = sourceResults.find(({ step: s }) => journeyStepsEqual(s, step)) if (!match) return {} const firstStepVisitors = existingFunnel[0]?.visitors ?? match.visitors @@ -94,6 +80,15 @@ function maybeEmptyResults(results, activeFilter, journeyEndEvent) { } } +// Two steps are identical when their identity fields match. +export function journeyStepsEqual(a, b) { + return ( + a.name === b.name && + a.pathname === b.pathname && + a.includes_subpaths === b.includes_subpaths + ) +} + export function emptyJourney() { return { steps: [], From 954254b039fdfbf3d07b394777241de97fca7463 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 14:40:40 +0200 Subject: [PATCH 10/21] Extract exploration state management --- .../dashboard/extra/exploration/constants.ts | 11 + .../extra/exploration/exploration-state.ts | 241 ++++++++++++++++ .../js/dashboard/extra/exploration/index.js | 261 +----------------- 3 files changed, 261 insertions(+), 252 deletions(-) create mode 100644 assets/js/dashboard/extra/exploration/constants.ts create mode 100644 assets/js/dashboard/extra/exploration/exploration-state.ts diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts new file mode 100644 index 000000000000..4b147e13bf84 --- /dev/null +++ b/assets/js/dashboard/extra/exploration/constants.ts @@ -0,0 +1,11 @@ +export const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } + +export const DIRECTION_OPTIONS = [ + { 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-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts new file mode 100644 index 000000000000..ce07c7023b63 --- /dev/null +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -0,0 +1,241 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { ApiError } from '../../api' +import * as api from '../../api' +import * as url from '../../util/url' +import { useSiteContext } from '../../site-context' +import { + emptyJourney, + toggleJourneyStep, + setJourneyActiveFilter, + clearJourneyFrozen, + clearJourneyFunnel, + clearJourneyRateLimit, + updateJourneyOnSuccess, + updateJourneyOnError, + updateJourneyOnRateLimitError +} from './journey' +import { DIRECTION, PAGE_FILTER_KEYS } from './constants' + +function isRateLimitedError(err) { + 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, 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 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 + } + ) +} + +// useExplorationData manages all async data fetching, cancellation, and +// journey state. +export function useExplorationData({ site, dashboardState, inViewport }) { + 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, step) => { + journeyVersionRef.current++ + setJourney((journey) => + toggleJourneyStep({ journey, columnIndex, newStep: step }) + ) + }, []) + + const reset = useCallback(() => { + ++journeyVersionRef.current + setActiveLoading(true) + setJourney(emptyJourney) + }, []) + + const setDirection = useCallback((newDirection) => { + if (newDirection === directionRef.current) return + directionRef.current = newDirection + ++journeyVersionRef.current + setJourney(emptyJourney) + setDirectionKey((k) => k + 1) + }, []) + + const setActiveFilter = useCallback((filter) => { + 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) + }) + }, [ + 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/index.js b/assets/js/dashboard/extra/exploration/index.js index c1807c7bb8b4..7f3e18513459 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -6,9 +6,6 @@ import React, { 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' @@ -22,31 +19,15 @@ 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' -import { - emptyJourney, - toggleJourneyStep, - setJourneyActiveFilter, - clearJourneyFrozen, - clearJourneyFunnel, - clearJourneyRateLimit, - updateJourneyOnSuccess, - updateJourneyOnError, - updateJourneyOnRateLimitError, - journeyStepsEqual -} from './journey' +import { useExplorationData } from './exploration-state' import { roundedPercentage } from './helpers' - -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 +import { journeyStepsEqual } from './journey' +import { + DIRECTION, + DIRECTION_OPTIONS, + MAX_VISIBLE_CANDIDATES, + MIN_GRID_COLUMNS +} from './constants' const EMPTY_SVG_DATA = { paths: [], @@ -56,37 +37,6 @@ const EMPTY_SVG_DATA = { clipHeight: 0 } -function isRateLimitedError(err) { - 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, 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 - }) - ) - ) -} - // Column header label based on index and direction. function columnHeader(index, direction) { if (index === 0) { @@ -96,26 +46,6 @@ function columnHeader(index, direction) { 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) { @@ -621,179 +551,6 @@ function ExplorationColumn({ ) } -// useExplorationData manages all async data fetching, cancellation, and -// journey state. -function useExplorationData(site, dashboardState, inViewport) { - 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, step) => { - journeyVersionRef.current++ - setJourney((journey) => - toggleJourneyStep({ journey, columnIndex, newStep: step }) - ) - }, []) - - const reset = useCallback(() => { - ++journeyVersionRef.current - setActiveLoading(true) - setJourney(emptyJourney) - }, []) - - const setDirection = useCallback((newDirection) => { - if (newDirection === directionRef.current) return - directionRef.current = newDirection - ++journeyVersionRef.current - setJourney(emptyJourney) - setDirectionKey((k) => k + 1) - }, []) - - const setActiveFilter = useCallback((filter) => { - 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) - }) - }, [ - 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 - } -} - // Scrolls the active column into view whenever the journey length changes. function useScrollActiveColumnIntoView(containerRef, stepsLength) { const prevLengthRef = useRef(0) @@ -843,7 +600,7 @@ export function FunnelExploration() { retry, setDirection, setActiveFilter - } = useExplorationData(site, dashboardState, inViewport) + } = useExplorationData({ site, dashboardState, inViewport }) const { steps, funnel, activeResults, activeFilter, frozen, provisional } = journey From 8ff3a144d31dfd81cb0c30a9b8b69781e104d05e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 14:53:09 +0200 Subject: [PATCH 11/21] Extract path connectors component --- .../js/dashboard/extra/exploration/index.js | 159 +----------------- .../extra/exploration/path-connectors.tsx | 153 +++++++++++++++++ 2 files changed, 155 insertions(+), 157 deletions(-) create mode 100644 assets/js/dashboard/extra/exploration/path-connectors.tsx diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index 7f3e18513459..0a6f3109cae9 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -1,10 +1,4 @@ -import React, { - useState, - useEffect, - useLayoutEffect, - useRef, - useCallback -} from 'react' +import React, { useState, useEffect, useRef } from 'react' import LazyLoader from '../../components/lazy-loader' import { Tooltip } from '../../util/tooltip' import { useDebounce } from '../../custom-hooks' @@ -19,6 +13,7 @@ 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' +import { PathConnectors } from './path-connectors' import { useExplorationData } from './exploration-state' import { roundedPercentage } from './helpers' import { journeyStepsEqual } from './journey' @@ -29,14 +24,6 @@ import { MIN_GRID_COLUMNS } from './constants' -const EMPTY_SVG_DATA = { - paths: [], - width: 0, - height: 0, - clipY: 0, - clipHeight: 0 -} - // Column header label based on index and direction. function columnHeader(index, direction) { if (index === 0) { @@ -46,148 +33,6 @@ function columnHeader(index, direction) { return `${index} step${index === 1 ? '' : 's'} ${word}` } -// 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) 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..ac09e01d7370 --- /dev/null +++ b/assets/js/dashboard/extra/exploration/path-connectors.tsx @@ -0,0 +1,153 @@ +import React, { useLayoutEffect, useCallback, useState } from 'react' + +function emptySVGData() { + 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, 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. +export function PathConnectors({ steps, containerRef, layoutKey }) { + 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 = 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) => ( + + ))} + + ) +} From 656c2cbaaf822e984985d63792897ca3f7209dbf Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 15:22:04 +0200 Subject: [PATCH 12/21] Extract exploration column components --- .../extra/exploration/exploration-column.tsx | 382 ++++++++++++++++++ .../js/dashboard/extra/exploration/index.js | 379 +---------------- 2 files changed, 385 insertions(+), 376 deletions(-) create mode 100644 assets/js/dashboard/extra/exploration/exploration-column.tsx 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..b7d6acd3d1de --- /dev/null +++ b/assets/js/dashboard/extra/exploration/exploration-column.tsx @@ -0,0 +1,382 @@ +import React, { useState, useEffect, useRef } 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 } from './journey' +import { + DIRECTION, + DIRECTION_OPTIONS, + MAX_VISIBLE_CANDIDATES +} from './constants' + +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 + + ) +} + +export function MaxDepthColumn({ colIndex, header }) { + 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 +}) { + 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 }) => ( + + ))} +
    + )} +
    + ) +} diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.js index 0a6f3109cae9..d30017e67caa 100644 --- a/assets/js/dashboard/extra/exploration/index.js +++ b/assets/js/dashboard/extra/exploration/index.js @@ -1,28 +1,18 @@ import React, { useState, useEffect, useRef } from 'react' import LazyLoader from '../../components/lazy-loader' 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 { 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 { roundedPercentage } from './helpers' -import { journeyStepsEqual } from './journey' -import { - DIRECTION, - DIRECTION_OPTIONS, - MAX_VISIBLE_CANDIDATES, - MIN_GRID_COLUMNS -} from './constants' +import { DIRECTION, MIN_GRID_COLUMNS } from './constants' // Column header label based on index and direction. function columnHeader(index, direction) { @@ -33,369 +23,6 @@ function columnHeader(index, direction) { return `${index} step${index === 1 ? '' : 's'} ${word}` } -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 }) => ( - - ))} -
    - )} -
    - ) -} - // Scrolls the active column into view whenever the journey length changes. function useScrollActiveColumnIntoView(containerRef, stepsLength) { const prevLengthRef = useRef(0) From 8f9d3595a7b12aa4caad9d71e1e6c9375c093d92 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 15:23:12 +0200 Subject: [PATCH 13/21] Rename main entry point to TS extension --- assets/js/dashboard/extra/exploration/{index.js => index.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename assets/js/dashboard/extra/exploration/{index.js => index.tsx} (100%) diff --git a/assets/js/dashboard/extra/exploration/index.js b/assets/js/dashboard/extra/exploration/index.tsx similarity index 100% rename from assets/js/dashboard/extra/exploration/index.js rename to assets/js/dashboard/extra/exploration/index.tsx From 6f0b9ce96a279c3e7d2d05147a22fb4a95c1c0ae Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 21:10:55 +0200 Subject: [PATCH 14/21] Define basic types for helpers and journey --- .../js/dashboard/extra/exploration/helpers.ts | 5 +- .../js/dashboard/extra/exploration/journey.ts | 146 +++++++++++++++--- 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/helpers.ts b/assets/js/dashboard/extra/exploration/helpers.ts index 6852b1b5544a..631aa4a945aa 100644 --- a/assets/js/dashboard/extra/exploration/helpers.ts +++ b/assets/js/dashboard/extra/exploration/helpers.ts @@ -1,7 +1,6 @@ -export function roundedPercentage(value, total) { - const percentage = (value / total) * 100 +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/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 14c203daf10b..f04f26b906e6 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -1,9 +1,58 @@ import { roundedPercentage } from './helpers' +type JourneyStep = { + name: string + pathname: string + includes_subpaths: boolean + subpaths_count: number + is_goal: boolean +} + +type JourneySuggestion = { + step: JourneyStep + visitors: number +} + +type FunnelStep = { + step: JourneyStep + visitors: number + dropoff: number + dropoff_percentage: number + conversion_rate: number + conversion_rate_step: number +} + +type ProvisionalFunnelStep = { + visitors: number + conversion_rate: number +} + +type FrozenSuggestions = { [columnIndex: string]: JourneySuggestion[] } +type ProvisionalFunnelSteps = { [columnIndex: string]: ProvisionalFunnelStep } + +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, fromIndex) { - const result = {} +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] } @@ -12,8 +61,15 @@ function truncateFrozenAt(frozen, fromIndex) { // 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 }) => journeyStepsEqual(s, step)) +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 @@ -23,7 +79,7 @@ function provisionalEntry(step, columnIndex, sourceResults, existingFunnel) { } } -function deselectStep(journey, columnIndex) { +function deselectStep(journey: Journey, columnIndex: number): Journey { // Deselect: truncate journey at columnIndex. return { ...journey, @@ -36,7 +92,11 @@ function deselectStep(journey, columnIndex) { } } -function selectStep(journey, columnIndex, newStep) { +function selectStep( + journey: Journey, + columnIndex: number, + newStep: JourneyStep +): Journey { // Select: determine source results for provisional values. const sourceResults = columnIndex === journey.steps.length @@ -67,7 +127,11 @@ function selectStep(journey, columnIndex, newStep) { } } -function maybeEmptyResults(results, activeFilter, journeyEndEvent) { +function maybeEmptyResults( + results: JourneySuggestion[], + activeFilter: string, + journeyEndEvent: string +): JourneySuggestion[] { if ( results.length === 0 || (!activeFilter && @@ -81,7 +145,7 @@ function maybeEmptyResults(results, activeFilter, journeyEndEvent) { } // Two steps are identical when their identity fields match. -export function journeyStepsEqual(a, b) { +export function journeyStepsEqual(a: JourneyStep, b: JourneyStep): boolean { return ( a.name === b.name && a.pathname === b.pathname && @@ -89,7 +153,7 @@ export function journeyStepsEqual(a, b) { ) } -export function emptyJourney() { +export function emptyJourney(): Journey { return { steps: [], funnel: [], @@ -102,7 +166,15 @@ export function emptyJourney() { } } -export function toggleJourneyStep({ journey, columnIndex, newStep }) { +export function toggleJourneyStep({ + journey, + columnIndex, + newStep +}: { + journey: Journey + columnIndex: number + newStep: JourneyStep +}): Journey { if (newStep === null) { return deselectStep(journey, columnIndex) } @@ -110,19 +182,25 @@ export function toggleJourneyStep({ journey, columnIndex, newStep }) { return selectStep(journey, columnIndex, newStep) } -export function setJourneyActiveFilter({ journey, filter }) { +export function setJourneyActiveFilter({ + journey, + filter +}: { + journey: Journey + filter: string +}): Journey { return { ...journey, activeFilter: filter } } -export function clearJourneyFrozen(journey) { +export function clearJourneyFrozen(journey: Journey): Journey { return { ...journey, frozen: {} } } -export function clearJourneyFunnel(journey) { +export function clearJourneyFunnel(journey: Journey): Journey { return { ...journey, funnel: [] } } -export function clearJourneyRateLimit(journey) { +export function clearJourneyRateLimit(journey: Journey): Journey { return { ...journey, rateLimited: false } } @@ -131,7 +209,12 @@ export function updateJourneyOnSuccess({ response, includeFunnel, journeyEndEvent -}) { +}: { + journey: Journey + response: JourneyResponse + includeFunnel: boolean + journeyEndEvent: string +}): Journey { const newJourney = { ...journey, activeResults: maybeEmptyResults( @@ -149,7 +232,10 @@ export function updateJourneyOnSuccess({ // 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) + 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) @@ -167,15 +253,17 @@ export function updateJourneyOnSuccess({ // entries and double-highlighted rows. const currentSteps = newJourney.steps ?? journey.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 + 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, idx) => s.subpaths_count !== currentSteps[idx].subpaths_count + (s: JourneyStep, idx: number): boolean => + s.subpaths_count !== currentSteps[idx].subpaths_count ) if (changed) newJourney.steps = synced } @@ -183,7 +271,13 @@ export function updateJourneyOnSuccess({ return newJourney } -export function updateJourneyOnRateLimitError({ journey, includeFunnel }) { +export function updateJourneyOnRateLimitError({ + journey, + includeFunnel +}: { + journey: Journey + includeFunnel: boolean +}): Journey { return { ...journey, frozen: truncateFrozenAt(journey.frozen, journey.steps.length), @@ -193,7 +287,13 @@ export function updateJourneyOnRateLimitError({ journey, includeFunnel }) { } } -export function updateJourneyOnError({ journey, includeFunnel }) { +export function updateJourneyOnError({ + journey, + includeFunnel +}: { + journey: Journey + includeFunnel: boolean +}): Journey { return { ...journey, frozen: truncateFrozenAt(journey.frozen, journey.steps.length), From ced88db6a37b94ebd0cd880b400e182142df2a16 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 May 2026 21:45:57 +0200 Subject: [PATCH 15/21] Add types to path connectors --- .../extra/exploration/path-connectors.tsx | 83 ++++++++++++++----- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/path-connectors.tsx b/assets/js/dashboard/extra/exploration/path-connectors.tsx index ac09e01d7370..0c3d9f63cb19 100644 --- a/assets/js/dashboard/extra/exploration/path-connectors.tsx +++ b/assets/js/dashboard/extra/exploration/path-connectors.tsx @@ -1,6 +1,26 @@ -import React, { useLayoutEffect, useCallback, useState } from 'react' +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() { +function emptySVGData(): SVGData { return { paths: [], width: 0, @@ -12,20 +32,25 @@ function emptySVGData() { // 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) { +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, containerRect) { +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, y1, x2, y2) { +function steppedPath(x1: number, y1: number, x2: number, y2: number): string { const mx = (x1 + x2) / 2 const dy = y2 - y1 @@ -44,14 +69,14 @@ function steppedPath(x1, y1, x2, y2) { // Clip rect that keeps connectors inside the list area, // preventing them from bleeding into column headers. -function listClipRect(container, containerRect) { +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, steps) { +function computeConnectors(container: Element, steps: JourneyStep[]): SVGData { const containerRect = container.getBoundingClientRect() const paths = [] @@ -85,7 +110,15 @@ function computeConnectors(container, steps) { // 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 }) { +export function PathConnectors({ + steps, + containerRef, + layoutKey +}: { + steps: JourneyStep[] + containerRef: RefObject + layoutKey: number +}): ReactNode | null { const [svgData, setSvgData] = useState(emptySVGData) const recalculate = useCallback(() => { @@ -107,15 +140,19 @@ export function PathConnectors({ steps, containerRef, layoutKey }) { observer.observe(container) window.addEventListener('resize', recalculate) - const lists = Array.from( + const lists: Element[] = Array.from( container.querySelectorAll('[data-exploration-list]') ) - lists.forEach((list) => list.addEventListener('scroll', recalculate)) + lists.forEach((list: Element): void => + list.addEventListener('scroll', recalculate) + ) - return () => { + return (): void => { observer.disconnect() window.removeEventListener('resize', recalculate) - lists.forEach((list) => list.removeEventListener('scroll', recalculate)) + lists.forEach((list: Element): void => + 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. @@ -138,16 +175,18 @@ export function PathConnectors({ steps, containerRef, layoutKey }) { /> - {svgData.paths.map((d, i) => ( - - ))} + {svgData.paths.map( + (d: string, i: number): ReactNode => ( + + ) + )} ) } From ae52468e1652360a9d99a01813eb35e6bc04d6a4 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 10:12:52 +0200 Subject: [PATCH 16/21] Add types to exploration state --- .../dashboard/extra/exploration/constants.ts | 14 +++- .../extra/exploration/exploration-state.ts | 83 ++++++++++++++----- .../js/dashboard/extra/exploration/journey.ts | 8 +- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts index 4b147e13bf84..4e3ad4862868 100644 --- a/assets/js/dashboard/extra/exploration/constants.ts +++ b/assets/js/dashboard/extra/exploration/constants.ts @@ -1,6 +1,16 @@ -export const DIRECTION = { FORWARD: 'forward', BACKWARD: 'backward' } +export type ExploratioDirection = 'forward' | 'backward' -export const DIRECTION_OPTIONS = [ +export const DIRECTION: { [label: string]: ExploratioDirection } = { + FORWARD: 'forward', + BACKWARD: 'backward' +} + +export type ExplorationDirectionOption = { + value: ExploratioDirection + label: string +} + +export const DIRECTION_OPTIONS: ExplorationDirectionOption[] = [ { value: DIRECTION.FORWARD, label: 'Starting point' }, { value: DIRECTION.BACKWARD, label: 'End point' } ] diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts index ce07c7023b63..e54b4c77d36c 100644 --- a/assets/js/dashboard/extra/exploration/exploration-state.ts +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -2,7 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { ApiError } from '../../api' import * as api from '../../api' import * as url from '../../util/url' -import { useSiteContext } from '../../site-context' +import { useSiteContext, PlausibleSite } from '../../site-context' +import { DashboardState } from '../../dashboard-state' import { emptyJourney, toggleJourneyStep, @@ -12,17 +13,42 @@ import { clearJourneyRateLimit, updateJourneyOnSuccess, updateJourneyOnError, - updateJourneyOnRateLimitError + updateJourneyOnRateLimitError, + JourneyStep, + Journey, + JourneySuggestion, + FunnelStep } from './journey' -import { DIRECTION, PAGE_FILTER_KEYS } from './constants' +import { DIRECTION, PAGE_FILTER_KEYS, ExploratioDirection } from './constants' -function isRateLimitedError(err) { +export type ExplorationData = { + journey: Journey + direction: ExploratioDirection + activeLoading: boolean + layoutKey: number + rateLimited: boolean + selectStep: (columnIndex: number, step: JourneyStep) => void + reset: () => void + retry: () => void + setDirection: (direction: ExploratioDirection) => 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, steps) { +function dashboardStateForQuery( + dashboardState: DashboardState, + steps: JourneyStep[] +): DashboardState { if (steps.length === 0) return dashboardState return { ...dashboardState, @@ -33,7 +59,7 @@ function dashboardStateForQuery(dashboardState, steps) { } // Serialize steps into the wire format expected by the API. -function stepsToJourneyParam(steps) { +function stepsToJourneyParam(steps: JourneyStep[]): string { return JSON.stringify( steps.map( ({ name, pathname, includes_subpaths, subpaths_count, is_goal }) => ({ @@ -48,13 +74,13 @@ function stepsToJourneyParam(steps) { } function fetchNextWithFunnel( - site, - dashboardState, - steps, - filter, - direction, - includeFunnel -) { + site: PlausibleSite, + dashboardState: DashboardState, + steps: JourneyStep[], + filter: string, + direction: ExploratioDirection, + includeFunnel: boolean +): Promise { return api.post( url.apiPath(site, '/exploration/next-with-funnel'), dashboardStateForQuery(dashboardState, steps), @@ -69,7 +95,15 @@ function fetchNextWithFunnel( // useExplorationData manages all async data fetching, cancellation, and // journey state. -export function useExplorationData({ site, dashboardState, inViewport }) { +export function useExplorationData({ + site, + dashboardState, + inViewport +}: { + site: PlausibleSite + dashboardState: DashboardState + inViewport: boolean +}): ExplorationData { const { explorationMaxJourneySteps: maxJourneySteps, explorationJourneyEndEvent: journeyEndEvent @@ -99,7 +133,7 @@ export function useExplorationData({ site, dashboardState, inViewport }) { // a steps state update. const directionRef = useRef(DIRECTION.FORWARD) - const selectStep = useCallback((columnIndex, step) => { + const selectStep = useCallback((columnIndex: number, step: JourneyStep) => { journeyVersionRef.current++ setJourney((journey) => toggleJourneyStep({ journey, columnIndex, newStep: step }) @@ -112,15 +146,18 @@ export function useExplorationData({ site, dashboardState, inViewport }) { setJourney(emptyJourney) }, []) - const setDirection = useCallback((newDirection) => { - if (newDirection === directionRef.current) return - directionRef.current = newDirection - ++journeyVersionRef.current - setJourney(emptyJourney) - setDirectionKey((k) => k + 1) - }, []) + const setDirection = useCallback( + (newDirection: ExploratioDirection): void => { + if (newDirection === directionRef.current) return + directionRef.current = newDirection + ++journeyVersionRef.current + setJourney(emptyJourney) + setDirectionKey((k) => k + 1) + }, + [] + ) - const setActiveFilter = useCallback((filter) => { + const setActiveFilter = useCallback((filter: string): void => { setJourney((journey) => setJourneyActiveFilter({ journey, filter })) }, []) diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index f04f26b906e6..dd26ce611e51 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -1,6 +1,6 @@ import { roundedPercentage } from './helpers' -type JourneyStep = { +export type JourneyStep = { name: string pathname: string includes_subpaths: boolean @@ -8,12 +8,12 @@ type JourneyStep = { is_goal: boolean } -type JourneySuggestion = { +export type JourneySuggestion = { step: JourneyStep visitors: number } -type FunnelStep = { +export type FunnelStep = { step: JourneyStep visitors: number dropoff: number @@ -30,7 +30,7 @@ type ProvisionalFunnelStep = { type FrozenSuggestions = { [columnIndex: string]: JourneySuggestion[] } type ProvisionalFunnelSteps = { [columnIndex: string]: ProvisionalFunnelStep } -type Journey = { +export type Journey = { steps: JourneyStep[] funnel: FunnelStep[] activeResults: JourneySuggestion[] From d3621cd87a7cfe284a174e14e7a543e6217d6a3b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 10:41:45 +0200 Subject: [PATCH 17/21] Add types to exploration column --- .../extra/exploration/exploration-column.tsx | 80 +++++++++++++++---- .../js/dashboard/extra/exploration/journey.ts | 1 + 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/exploration-column.tsx b/assets/js/dashboard/extra/exploration/exploration-column.tsx index b7d6acd3d1de..3155d21e5311 100644 --- a/assets/js/dashboard/extra/exploration/exploration-column.tsx +++ b/assets/js/dashboard/extra/exploration/exploration-column.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, ReactNode, RefObject } from 'react' import { Tooltip } from '../../util/tooltip' import { useSiteContext } from '../../site-context' import { useDebounce } from '../../custom-hooks' @@ -11,21 +11,31 @@ 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 } from './journey' +import { journeyStepsEqual, JourneyStep, JourneySuggestion } from './journey' import { DIRECTION, DIRECTION_OPTIONS, - MAX_VISIBLE_CANDIDATES + MAX_VISIBLE_CANDIDATES, + ExploratioDirection } from './constants' -function DirectionDropdown({ direction, onChange }) { +function DirectionDropdown({ + direction, + onChange +}: { + direction: ExploratioDirection + onChange: (direction: ExploratioDirection) => void +}): ReactNode { const [open, setOpen] = useState(false) - const containerRef = useRef(null) + const containerRef: RefObject = useRef(null) useEffect(() => { if (!open) return - function onClickOutside(e) { - if (containerRef.current && !containerRef.current.contains(e.target)) { + function onClickOutside(e: Event) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as HTMLElement) + ) { setOpen(false) } } @@ -82,7 +92,17 @@ function CandidateCard({ 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 = @@ -171,7 +191,7 @@ function CandidateCard({ ) } -function VisitorsMetric({ visitors }) { +function VisitorsMetric({ visitors }: { visitors: number }): ReactNode { const shortNumber = numberShortFormatter(visitors) const longNumber = numberLongFormatter(visitors) const showTooltip = shortNumber !== longNumber @@ -194,7 +214,14 @@ function ColumnEmptyState({ direction, rateLimited, onRetry -}) { +}: { + active: boolean + filter: string + colIndex: number + direction: ExploratioDirection + rateLimited: boolean + onRetry: () => void +}): ReactNode { if (active && rateLimited) { return ( @@ -242,7 +269,13 @@ function ColumnEmptyState({ ) } -export function MaxDepthColumn({ colIndex, header }) { +export function MaxDepthColumn({ + colIndex, + header +}: { + colIndex: number + header: string +}): ReactNode { const { explorationMaxJourneySteps: maxJourneySteps } = useSiteContext() return (
    - onFilterChange(e.target.value) +}: { + colIndex: number + direction: ExploratioDirection + onDirectionChange: (direction: ExploratioDirection) => void + header: string + headerConversionRate: number + 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, diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index dd26ce611e51..066bb32cfdcf 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -1,6 +1,7 @@ import { roundedPercentage } from './helpers' export type JourneyStep = { + label: string name: string pathname: string includes_subpaths: boolean From febbd1cde9137844806de657692fc30bc4432f9f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 10:58:48 +0200 Subject: [PATCH 18/21] Add types to main exploration module --- .../js/dashboard/extra/exploration/constants.ts | 6 +++--- .../extra/exploration/exploration-column.tsx | 14 +++++++------- .../extra/exploration/exploration-state.ts | 14 +++++++------- assets/js/dashboard/extra/exploration/index.tsx | 15 +++++++++------ assets/js/dashboard/extra/exploration/journey.ts | 6 +++--- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts index 4e3ad4862868..b0f27f9bf3d7 100644 --- a/assets/js/dashboard/extra/exploration/constants.ts +++ b/assets/js/dashboard/extra/exploration/constants.ts @@ -1,12 +1,12 @@ -export type ExploratioDirection = 'forward' | 'backward' +export type ExplorationDirection = 'forward' | 'backward' -export const DIRECTION: { [label: string]: ExploratioDirection } = { +export const DIRECTION: { [label: string]: ExplorationDirection } = { FORWARD: 'forward', BACKWARD: 'backward' } export type ExplorationDirectionOption = { - value: ExploratioDirection + value: ExplorationDirection label: string } diff --git a/assets/js/dashboard/extra/exploration/exploration-column.tsx b/assets/js/dashboard/extra/exploration/exploration-column.tsx index 3155d21e5311..ac6450d127fb 100644 --- a/assets/js/dashboard/extra/exploration/exploration-column.tsx +++ b/assets/js/dashboard/extra/exploration/exploration-column.tsx @@ -16,15 +16,15 @@ import { DIRECTION, DIRECTION_OPTIONS, MAX_VISIBLE_CANDIDATES, - ExploratioDirection + ExplorationDirection } from './constants' function DirectionDropdown({ direction, onChange }: { - direction: ExploratioDirection - onChange: (direction: ExploratioDirection) => void + direction: ExplorationDirection + onChange: (direction: ExplorationDirection) => void }): ReactNode { const [open, setOpen] = useState(false) const containerRef: RefObject = useRef(null) @@ -218,7 +218,7 @@ function ColumnEmptyState({ active: boolean filter: string colIndex: number - direction: ExploratioDirection + direction: ExplorationDirection rateLimited: boolean onRetry: () => void }): ReactNode { @@ -319,10 +319,10 @@ export function ExplorationColumn({ onRetry }: { colIndex: number - direction: ExploratioDirection - onDirectionChange: (direction: ExploratioDirection) => void + direction: ExplorationDirection + onDirectionChange: ((direction: ExplorationDirection) => void) | undefined header: string - headerConversionRate: number + headerConversionRate: string | null active: boolean loading: boolean loadingInBackground: boolean diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts index e54b4c77d36c..4298977da769 100644 --- a/assets/js/dashboard/extra/exploration/exploration-state.ts +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -19,18 +19,18 @@ import { JourneySuggestion, FunnelStep } from './journey' -import { DIRECTION, PAGE_FILTER_KEYS, ExploratioDirection } from './constants' +import { DIRECTION, PAGE_FILTER_KEYS, ExplorationDirection } from './constants' export type ExplorationData = { journey: Journey - direction: ExploratioDirection + direction: ExplorationDirection activeLoading: boolean layoutKey: number rateLimited: boolean - selectStep: (columnIndex: number, step: JourneyStep) => void + selectStep: (columnIndex: number, step: JourneyStep | null) => void reset: () => void retry: () => void - setDirection: (direction: ExploratioDirection) => void + setDirection: (direction: ExplorationDirection) => void setActiveFilter: (filter: string) => void } @@ -78,7 +78,7 @@ function fetchNextWithFunnel( dashboardState: DashboardState, steps: JourneyStep[], filter: string, - direction: ExploratioDirection, + direction: ExplorationDirection, includeFunnel: boolean ): Promise { return api.post( @@ -133,7 +133,7 @@ export function useExplorationData({ // a steps state update. const directionRef = useRef(DIRECTION.FORWARD) - const selectStep = useCallback((columnIndex: number, step: JourneyStep) => { + const selectStep = useCallback((columnIndex: number, step: JourneyStep | null) => { journeyVersionRef.current++ setJourney((journey) => toggleJourneyStep({ journey, columnIndex, newStep: step }) @@ -147,7 +147,7 @@ export function useExplorationData({ }, []) const setDirection = useCallback( - (newDirection: ExploratioDirection): void => { + (newDirection: ExplorationDirection): void => { if (newDirection === directionRef.current) return directionRef.current = newDirection ++journeyVersionRef.current diff --git a/assets/js/dashboard/extra/exploration/index.tsx b/assets/js/dashboard/extra/exploration/index.tsx index d30017e67caa..430f73a9334a 100644 --- a/assets/js/dashboard/extra/exploration/index.tsx +++ b/assets/js/dashboard/extra/exploration/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, RefObject } from 'react' import LazyLoader from '../../components/lazy-loader' import { Tooltip } from '../../util/tooltip' import { useSiteContext } from '../../site-context' @@ -12,10 +12,10 @@ 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 } from './constants' +import { DIRECTION, MIN_GRID_COLUMNS, ExplorationDirection } from './constants' // Column header label based on index and direction. -function columnHeader(index, direction) { +function columnHeader(index: number, direction: ExplorationDirection): string { if (index === 0) { return direction === DIRECTION.BACKWARD ? 'End point' : 'Starting point' } @@ -24,7 +24,10 @@ function columnHeader(index, direction) { } // Scrolls the active column into view whenever the journey length changes. -function useScrollActiveColumnIntoView(containerRef, stepsLength) { +function useScrollActiveColumnIntoView( + containerRef: RefObject, + stepsLength: number +) { const prevLengthRef = useRef(0) useEffect(() => { @@ -123,10 +126,10 @@ export function FunnelExploration() {
    - CR: {percentageFormatter(parseFloat(overallConversionRate))}{' '} + CR: {percentageFormatter(parseFloat(overallConversionRate!))}{' '} - ({numberShortFormatter(overallConversionVisitors)}) + ({numberShortFormatter(overallConversionVisitors!)}) diff --git a/assets/js/dashboard/extra/exploration/journey.ts b/assets/js/dashboard/extra/exploration/journey.ts index 066bb32cfdcf..e8707b464d39 100644 --- a/assets/js/dashboard/extra/exploration/journey.ts +++ b/assets/js/dashboard/extra/exploration/journey.ts @@ -19,8 +19,8 @@ export type FunnelStep = { visitors: number dropoff: number dropoff_percentage: number - conversion_rate: number - conversion_rate_step: number + conversion_rate: string + conversion_rate_step: string } type ProvisionalFunnelStep = { @@ -174,7 +174,7 @@ export function toggleJourneyStep({ }: { journey: Journey columnIndex: number - newStep: JourneyStep + newStep: JourneyStep | null }): Journey { if (newStep === null) { return deselectStep(journey, columnIndex) From aca451dc3c3f9c3af997c5bcce977b77cd3c159b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 11:02:35 +0200 Subject: [PATCH 19/21] Remove some excess typing --- assets/js/dashboard/extra/exploration/constants.ts | 9 ++++----- .../dashboard/extra/exploration/exploration-state.ts | 4 ++-- .../js/dashboard/extra/exploration/path-connectors.tsx | 10 +++------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts index b0f27f9bf3d7..ee2363b1968f 100644 --- a/assets/js/dashboard/extra/exploration/constants.ts +++ b/assets/js/dashboard/extra/exploration/constants.ts @@ -1,15 +1,14 @@ export type ExplorationDirection = 'forward' | 'backward' +type ExplorationDirectionOption = { + value: ExplorationDirection + label: string +} export const DIRECTION: { [label: string]: ExplorationDirection } = { FORWARD: 'forward', BACKWARD: 'backward' } -export type ExplorationDirectionOption = { - value: ExplorationDirection - label: string -} - export const DIRECTION_OPTIONS: ExplorationDirectionOption[] = [ { value: DIRECTION.FORWARD, label: 'Starting point' }, { value: DIRECTION.BACKWARD, label: 'End point' } diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts index 4298977da769..811890347392 100644 --- a/assets/js/dashboard/extra/exploration/exploration-state.ts +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -147,7 +147,7 @@ export function useExplorationData({ }, []) const setDirection = useCallback( - (newDirection: ExplorationDirection): void => { + (newDirection: ExplorationDirection) => { if (newDirection === directionRef.current) return directionRef.current = newDirection ++journeyVersionRef.current @@ -157,7 +157,7 @@ export function useExplorationData({ [] ) - const setActiveFilter = useCallback((filter: string): void => { + const setActiveFilter = useCallback((filter: string) => { setJourney((journey) => setJourneyActiveFilter({ journey, filter })) }, []) diff --git a/assets/js/dashboard/extra/exploration/path-connectors.tsx b/assets/js/dashboard/extra/exploration/path-connectors.tsx index 0c3d9f63cb19..6d053b230d64 100644 --- a/assets/js/dashboard/extra/exploration/path-connectors.tsx +++ b/assets/js/dashboard/extra/exploration/path-connectors.tsx @@ -143,16 +143,12 @@ export function PathConnectors({ const lists: Element[] = Array.from( container.querySelectorAll('[data-exploration-list]') ) - lists.forEach((list: Element): void => - list.addEventListener('scroll', recalculate) - ) + lists.forEach((list) => list.addEventListener('scroll', recalculate)) - return (): void => { + return () => { observer.disconnect() window.removeEventListener('resize', recalculate) - lists.forEach((list: Element): void => - list.removeEventListener('scroll', 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. From efaca75720e971defaaedc63b44a53fc61b3b63f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 11:03:48 +0200 Subject: [PATCH 20/21] Fix TS formatting --- .../extra/exploration/exploration-state.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts index 811890347392..1672badf4fef 100644 --- a/assets/js/dashboard/extra/exploration/exploration-state.ts +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -133,12 +133,15 @@ export function useExplorationData({ // 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 selectStep = useCallback( + (columnIndex: number, step: JourneyStep | null) => { + journeyVersionRef.current++ + setJourney((journey) => + toggleJourneyStep({ journey, columnIndex, newStep: step }) + ) + }, + [] + ) const reset = useCallback(() => { ++journeyVersionRef.current @@ -146,16 +149,13 @@ export function useExplorationData({ setJourney(emptyJourney) }, []) - const setDirection = useCallback( - (newDirection: ExplorationDirection) => { - if (newDirection === directionRef.current) return - directionRef.current = newDirection - ++journeyVersionRef.current - setJourney(emptyJourney) - setDirectionKey((k) => k + 1) - }, - [] - ) + 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 })) From bd4ace34d867cef4b6b887df6425de5ed50e7b56 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 21 May 2026 11:20:07 +0200 Subject: [PATCH 21/21] Silence hook dependency warnings concerning constatnt values from ctx --- assets/js/dashboard/extra/exploration/exploration-state.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/js/dashboard/extra/exploration/exploration-state.ts b/assets/js/dashboard/extra/exploration/exploration-state.ts index 1672badf4fef..ad3ce6a79c2d 100644 --- a/assets/js/dashboard/extra/exploration/exploration-state.ts +++ b/assets/js/dashboard/extra/exploration/exploration-state.ts @@ -245,6 +245,8 @@ export function useExplorationData({ .finally(() => { if (!isStale()) setActiveLoading(false) }) + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ site, dashboardState,