Skip to content

Commit d298071

Browse files
authored
Implement basics of funnel exploration logic along with UI prototype (#6242)
* Implement basics of exploration logic * Add simplistic search capability * Implement calculation of dropoff as a number and a percentage * Support querying for the first step * Expose exploration via internal API * Only allow superadmins to query exploration API for now * Hook up a very basic UI adapted from earlier prototype * Change the approach to gating access to exploration in UI * Fix exploration funnel length condition * Add primitive filtering support * Revise tests for exploration slightly * Ensure filter input is populated on reveal and simplify debounce logic * Remove unreachable case clause * Fix tests * Add tests for exploration available flag * Address credo complaints * Remove unused JS import * Move exploration logic outside extra * Run one of the tests on EE only * Run tests reyling in superadmin guard on EE only * Refactor `percentage` helper and extract it to `Plausible.Stats.Util`
1 parent 49e818d commit d298071

16 files changed

Lines changed: 972 additions & 66 deletions

File tree

assets/js/dashboard/site-context.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('parseSiteFromDataset', () => {
1515
data-funnels-opted-out="false"
1616
data-props-opted-out="false"
1717
data-funnels-available="true"
18+
data-exploration-available="false"
1819
data-site-segments-available="true"
1920
data-props-available="true"
2021
data-revenue-goals='[{"currency":"USD","display_name":"Purchase"}]'
@@ -43,6 +44,7 @@ describe('parseSiteFromDataset', () => {
4344
propsOptedOut: false,
4445
funnelsAvailable: true,
4546
propsAvailable: true,
47+
explorationAvailable: false,
4648
siteSegmentsAvailable: true,
4749
revenueGoals: [{ currency: 'USD', display_name: 'Purchase' }],
4850
funnels: [{ id: 1, name: 'From homepage to login', steps_count: 3 }],

assets/js/dashboard/site-context.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
88
hasProps: dataset.hasProps === 'true',
99
funnelsAvailable: dataset.funnelsAvailable === 'true',
1010
propsAvailable: dataset.propsAvailable === 'true',
11+
explorationAvailable: dataset.explorationAvailable === 'true',
1112
siteSegmentsAvailable: dataset.siteSegmentsAvailable === 'true',
1213
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
1314
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
@@ -36,6 +37,7 @@ export const siteContextDefaultValue = {
3637
hasGoals: false,
3738
hasProps: false,
3839
funnelsAvailable: false,
40+
explorationAvailable: false,
3941
propsAvailable: false,
4042
siteSegmentsAvailable: false,
4143
conversionsOptedOut: false,

assets/js/dashboard/stats/behaviours/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'
22
import * as storage from '../../util/storage'
33
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
44
import Properties from './props'
5+
import { FunnelExploration } from '../exploration'
56
import { FeatureSetupNotice } from '../../components/notice'
67
import {
78
hasConversionGoalFilter,
@@ -290,6 +291,10 @@ function Behaviours({ importedDataInView, setMode, mode }) {
290291
}
291292
}
292293

294+
function renderExploration() {
295+
return <FunnelExploration />
296+
}
297+
293298
function renderFunnels() {
294299
if (Funnel === null) {
295300
return featureUnavailable()
@@ -380,6 +385,8 @@ function Behaviours({ importedDataInView, setMode, mode }) {
380385
return renderProps()
381386
case Mode.FUNNELS:
382387
return renderFunnels()
388+
case Mode.EXPLORATION:
389+
return renderExploration()
383390
}
384391
}
385392

@@ -518,6 +525,14 @@ function Behaviours({ importedDataInView, setMode, mode }) {
518525
Funnels
519526
</TabButton>
520527
))}
528+
{!site.isConsolidatedView && site.explorationAvailable && (
529+
<TabButton
530+
active={mode === Mode.EXPLORATION}
531+
onClick={setTabFactory(Mode.EXPLORATION)}
532+
>
533+
Exploration
534+
</TabButton>
535+
)}
521536
</TabWrapper>
522537
{isRealtime() && <Pill className="-mt-1">last 30min</Pill>}
523538
{renderImportedQueryUnsupportedWarning()}

assets/js/dashboard/stats/behaviours/modes-context.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { UserContextValue, useUserContext } from '../../user-context'
55
export enum Mode {
66
CONVERSIONS = 'conversions',
77
PROPS = 'props',
8-
FUNNELS = 'funnels'
8+
FUNNELS = 'funnels',
9+
EXPLORATION = 'exploration'
910
}
1011

1112
export const MODES = {
@@ -23,6 +24,11 @@ export const MODES = {
2324
title: 'Funnels',
2425
isAvailableKey: `${Mode.FUNNELS}Available`,
2526
optedOutKey: `${Mode.FUNNELS}OptedOut`
27+
},
28+
[Mode.EXPLORATION]: {
29+
title: 'Exploration',
30+
isAvailableKey: null, // always available
31+
optedOutKey: null
2632
}
2733
} as const
2834

@@ -48,7 +54,7 @@ function getInitiallyAvailableModes({
4854
}): Mode[] {
4955
return Object.entries(MODES)
5056
.filter(([_, { isAvailableKey, optedOutKey }]) => {
51-
const isOptedOut = site[optedOutKey]
57+
const isOptedOut = optedOutKey ? site[optedOutKey] : false
5258
const isAvailable = isAvailableKey ? site[isAvailableKey] : true
5359

5460
// If the feature is not supported by the site owner's subscription,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, { useState, useEffect } from 'react'
2+
import * as api from '../api'
3+
import * as url from '../util/url'
4+
import { useDebounce } from '../custom-hooks'
5+
import { useSiteContext } from '../site-context'
6+
import { useDashboardStateContext } from '../dashboard-state-context'
7+
import { numberShortFormatter } from '../util/number-formatter'
8+
9+
const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page']
10+
11+
function fetchColumnData(site, dashboardState, steps, filter) {
12+
// Page filters only apply to the first step — strip them for subsequent columns
13+
const stateToUse =
14+
steps.length > 0
15+
? {
16+
...dashboardState,
17+
filters: dashboardState.filters.filter(
18+
([_op, key]) => !PAGE_FILTER_KEYS.includes(key)
19+
)
20+
}
21+
: dashboardState
22+
23+
const journey = []
24+
if (steps.length > 0) {
25+
for (const s of steps) {
26+
journey.push({ name: s.name, pathname: s.pathname })
27+
}
28+
}
29+
30+
return api.get(url.apiPath(site, '/exploration/next'), stateToUse, {
31+
journey: JSON.stringify(journey),
32+
search_term: filter
33+
})
34+
}
35+
36+
function ExplorationColumn({
37+
header,
38+
steps,
39+
selected,
40+
onSelect,
41+
dashboardState
42+
}) {
43+
const site = useSiteContext()
44+
const [loading, setLoading] = useState(steps !== null)
45+
const [results, setResults] = useState([])
46+
const [filter, setFilter] = useState('')
47+
48+
const debouncedOnSearchInputChange = useDebounce((event) =>
49+
setFilter(event.target.value)
50+
)
51+
52+
useEffect(() => {
53+
if (steps === null) {
54+
setFilter('')
55+
setResults([])
56+
setLoading(false)
57+
return
58+
}
59+
60+
setLoading(true)
61+
setResults([])
62+
63+
fetchColumnData(site, dashboardState, steps, filter)
64+
.then((response) => {
65+
setResults(response || [])
66+
})
67+
.catch(() => {
68+
setResults([])
69+
})
70+
.finally(() => {
71+
setLoading(false)
72+
})
73+
// eslint-disable-next-line react-hooks/exhaustive-deps
74+
}, [dashboardState, steps, filter])
75+
76+
const maxVisitors = results.length > 0 ? results[0].visitors : 1
77+
78+
return (
79+
<div className="flex-1 min-w-0 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
80+
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
81+
<span className="text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
82+
{header}
83+
</span>
84+
{!selected && steps !== null && (
85+
<input
86+
data-testid="search-input"
87+
type="text"
88+
defaultValue={filter}
89+
placeholder="Search"
90+
onChange={debouncedOnSearchInputChange}
91+
className="peer w-32 text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500"
92+
/>
93+
)}
94+
{selected && (
95+
<button
96+
onClick={() => onSelect(null)}
97+
className="text-xs text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-200"
98+
>
99+
Clear
100+
</button>
101+
)}
102+
</div>
103+
104+
{loading ? (
105+
<div className="flex items-center justify-center h-48">
106+
<div className="mx-auto loading pt-4">
107+
<div></div>
108+
</div>
109+
</div>
110+
) : results.length === 0 ? (
111+
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
112+
{steps === null ? 'Select an event to continue' : 'No data'}
113+
</div>
114+
) : (
115+
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
116+
{(selected
117+
? results.filter(
118+
({ step }) =>
119+
step.name === selected.name &&
120+
step.pathname === selected.pathname
121+
)
122+
: results.slice(0, 10)
123+
).map(({ step, visitors }) => {
124+
const label = `${step.name} ${step.pathname}`
125+
const pct = Math.round((visitors / maxVisitors) * 100)
126+
const isSelected =
127+
!!selected &&
128+
step.name === selected.name &&
129+
step.pathname === selected.pathname
130+
131+
return (
132+
<li key={label}>
133+
<button
134+
className={`w-full text-left px-4 py-2 text-sm transition-colors focus:outline-none ${
135+
isSelected
136+
? 'bg-indigo-50 dark:bg-indigo-900/30'
137+
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
138+
}`}
139+
onClick={() => onSelect(isSelected ? null : step)}
140+
>
141+
<div className="flex items-center justify-between mb-1">
142+
<span
143+
className={`truncate font-medium ${
144+
isSelected
145+
? 'text-indigo-700 dark:text-indigo-300'
146+
: 'text-gray-800 dark:text-gray-200'
147+
}`}
148+
title={label}
149+
>
150+
{label}
151+
</span>
152+
<span className="ml-2 shrink-0 text-gray-500 dark:text-gray-400 tabular-nums">
153+
{numberShortFormatter(visitors)}
154+
</span>
155+
</div>
156+
<div className="h-1 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
157+
<div
158+
className={`h-full rounded-full ${
159+
isSelected
160+
? 'bg-indigo-500'
161+
: 'bg-indigo-300 dark:bg-indigo-600'
162+
}`}
163+
style={{ width: `${pct}%` }}
164+
/>
165+
</div>
166+
</button>
167+
</li>
168+
)
169+
})}
170+
</ul>
171+
)}
172+
</div>
173+
)
174+
}
175+
176+
function columnHeader(index) {
177+
if (index === 0) return 'Start'
178+
return `${index} step${index === 1 ? '' : 's'} after`
179+
}
180+
181+
export function FunnelExploration() {
182+
const { dashboardState } = useDashboardStateContext()
183+
const [steps, setSteps] = useState([])
184+
185+
function handleSelect(columnIndex, selected) {
186+
if (selected === null) {
187+
setSteps(steps.slice(0, columnIndex))
188+
} else {
189+
setSteps([...steps.slice(0, columnIndex), selected])
190+
}
191+
}
192+
193+
const numColumns = Math.max(steps.length + 1, 3)
194+
195+
return (
196+
<div className="p-4">
197+
<h4 className="mt-2 mb-4 text-base font-semibold dark:text-gray-100">
198+
Explore user journeys
199+
</h4>
200+
<div className="flex gap-3">
201+
{Array.from({ length: numColumns }, (_, i) => (
202+
<ExplorationColumn
203+
key={i}
204+
header={columnHeader(i)}
205+
steps={steps.length >= i ? steps.slice(0, i) : null}
206+
selected={steps[i] || null}
207+
onSelect={(selected) => handleSelect(i, selected)}
208+
dashboardState={dashboardState}
209+
/>
210+
))}
211+
</div>
212+
</div>
213+
)
214+
}
215+
216+
export default FunnelExploration

assets/test-utils/app-context-providers.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const DEFAULT_SITE: PlausibleSite = {
2929
hasGoals: false,
3030
hasProps: false,
3131
funnelsAvailable: false,
32+
explorationAvailable: false,
3233
propsAvailable: false,
3334
siteSegmentsAvailable: false,
3435
conversionsOptedOut: false,

extra/lib/plausible/stats/funnel.ex

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Plausible.Stats.Funnel do
1111

1212
import Ecto.Query
1313
import Plausible.Stats.SQL.Fragments
14+
import Plausible.Stats.Util, only: [percentage: 2]
1415

1516
alias Plausible.ClickhouseRepo
1617
alias Plausible.Stats.{Base, Query}
@@ -167,24 +168,4 @@ defmodule Plausible.Stats.Funnel do
167168
|> elem(2)
168169
|> Enum.reverse()
169170
end
170-
171-
defp percentage(x, y) when x in [0, nil] or y in [0, nil] do
172-
"0"
173-
end
174-
175-
defp percentage(x, y) do
176-
result =
177-
x
178-
|> Decimal.div(y)
179-
|> Decimal.mult(100)
180-
|> Decimal.round(2)
181-
|> Decimal.to_string()
182-
183-
case result do
184-
<<compact::binary-size(1), ".00">> -> compact
185-
<<compact::binary-size(2), ".00">> -> compact
186-
<<compact::binary-size(3), ".00">> -> compact
187-
decimal -> decimal
188-
end
189-
end
190171
end

0 commit comments

Comments
 (0)