Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/js/dashboard/site-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('parseSiteFromDataset', () => {
data-funnels-opted-out="false"
data-props-opted-out="false"
data-funnels-available="true"
data-exploration-available="false"
data-site-segments-available="true"
data-props-available="true"
data-revenue-goals='[{"currency":"USD","display_name":"Purchase"}]'
Expand Down Expand Up @@ -43,6 +44,7 @@ describe('parseSiteFromDataset', () => {
propsOptedOut: false,
funnelsAvailable: true,
propsAvailable: true,
explorationAvailable: false,
siteSegmentsAvailable: true,
revenueGoals: [{ currency: 'USD', display_name: 'Purchase' }],
funnels: [{ id: 1, name: 'From homepage to login', steps_count: 3 }],
Expand Down
2 changes: 2 additions & 0 deletions assets/js/dashboard/site-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
hasProps: dataset.hasProps === 'true',
funnelsAvailable: dataset.funnelsAvailable === 'true',
propsAvailable: dataset.propsAvailable === 'true',
explorationAvailable: dataset.explorationAvailable === 'true',
siteSegmentsAvailable: dataset.siteSegmentsAvailable === 'true',
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
Expand Down Expand Up @@ -36,6 +37,7 @@ export const siteContextDefaultValue = {
hasGoals: false,
hasProps: false,
funnelsAvailable: false,
explorationAvailable: false,
propsAvailable: false,
siteSegmentsAvailable: false,
conversionsOptedOut: false,
Expand Down
15 changes: 15 additions & 0 deletions assets/js/dashboard/stats/behaviours/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'
import * as storage from '../../util/storage'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import Properties from './props'
import { FunnelExploration } from '../exploration'
import { FeatureSetupNotice } from '../../components/notice'
import {
hasConversionGoalFilter,
Expand Down Expand Up @@ -290,6 +291,10 @@ function Behaviours({ importedDataInView, setMode, mode }) {
}
}

function renderExploration() {
return <FunnelExploration />
}

function renderFunnels() {
if (Funnel === null) {
return featureUnavailable()
Expand Down Expand Up @@ -380,6 +385,8 @@ function Behaviours({ importedDataInView, setMode, mode }) {
return renderProps()
case Mode.FUNNELS:
return renderFunnels()
case Mode.EXPLORATION:
return renderExploration()
}
}

Expand Down Expand Up @@ -518,6 +525,14 @@ function Behaviours({ importedDataInView, setMode, mode }) {
Funnels
</TabButton>
))}
{!site.isConsolidatedView && site.explorationAvailable && (
<TabButton
active={mode === Mode.EXPLORATION}
onClick={setTabFactory(Mode.EXPLORATION)}
>
Exploration
</TabButton>
)}
</TabWrapper>
{isRealtime() && <Pill className="-mt-1">last 30min</Pill>}
{renderImportedQueryUnsupportedWarning()}
Expand Down
10 changes: 8 additions & 2 deletions assets/js/dashboard/stats/behaviours/modes-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { UserContextValue, useUserContext } from '../../user-context'
export enum Mode {
CONVERSIONS = 'conversions',
PROPS = 'props',
FUNNELS = 'funnels'
FUNNELS = 'funnels',
EXPLORATION = 'exploration'
}

export const MODES = {
Expand All @@ -23,6 +24,11 @@ export const MODES = {
title: 'Funnels',
isAvailableKey: `${Mode.FUNNELS}Available`,
optedOutKey: `${Mode.FUNNELS}OptedOut`
},
[Mode.EXPLORATION]: {
title: 'Exploration',
isAvailableKey: null, // always available
optedOutKey: null
}
} as const

Expand All @@ -48,7 +54,7 @@ function getInitiallyAvailableModes({
}): Mode[] {
return Object.entries(MODES)
.filter(([_, { isAvailableKey, optedOutKey }]) => {
const isOptedOut = site[optedOutKey]
const isOptedOut = optedOutKey ? site[optedOutKey] : false
const isAvailable = isAvailableKey ? site[isAvailableKey] : true

// If the feature is not supported by the site owner's subscription,
Expand Down
216 changes: 216 additions & 0 deletions assets/js/dashboard/stats/exploration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React, { useState, useEffect } from 'react'
import * as api from '../api'
import * as url from '../util/url'
import { useDebounce } from '../custom-hooks'
import { useSiteContext } from '../site-context'
import { useDashboardStateContext } from '../dashboard-state-context'
import { numberShortFormatter } from '../util/number-formatter'

const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page']

function fetchColumnData(site, dashboardState, steps, filter) {
// Page filters only apply to the first step — strip them for subsequent columns
const stateToUse =
steps.length > 0
? {
...dashboardState,
filters: dashboardState.filters.filter(
([_op, key]) => !PAGE_FILTER_KEYS.includes(key)
)
}
: dashboardState

const journey = []
if (steps.length > 0) {
for (const s of steps) {
journey.push({ name: s.name, pathname: s.pathname })
}
}

return api.get(url.apiPath(site, '/exploration/next'), stateToUse, {
journey: JSON.stringify(journey),
search_term: filter
})
}

function ExplorationColumn({
header,
steps,
selected,
onSelect,
dashboardState
}) {
const site = useSiteContext()
const [loading, setLoading] = useState(steps !== null)
const [results, setResults] = useState([])
const [filter, setFilter] = useState('')

const debouncedOnSearchInputChange = useDebounce((event) =>
setFilter(event.target.value)
)

useEffect(() => {
if (steps === null) {
setFilter('')
setResults([])
setLoading(false)
return
}

setLoading(true)
setResults([])

fetchColumnData(site, dashboardState, steps, filter)
.then((response) => {
setResults(response || [])
})
.catch(() => {
setResults([])
})
.finally(() => {
setLoading(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardState, steps, filter])

const maxVisitors = results.length > 0 ? results[0].visitors : 1

return (
<div className="flex-1 min-w-0 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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">
<span className="text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
{header}
</span>
{!selected && steps !== null && (
<input
data-testid="search-input"
type="text"
defaultValue={filter}
placeholder="Search"
onChange={debouncedOnSearchInputChange}
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"
/>
)}
{selected && (
<button
onClick={() => onSelect(null)}
className="text-xs text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-200"
>
Clear
</button>
)}
</div>

{loading ? (
<div className="flex items-center justify-center h-48">
<div className="mx-auto loading pt-4">
<div></div>
</div>
</div>
) : results.length === 0 ? (
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
{steps === null ? 'Select an event to continue' : 'No data'}
</div>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{(selected
? results.filter(
({ step }) =>
step.name === selected.name &&
step.pathname === selected.pathname
)
: results.slice(0, 10)
).map(({ step, visitors }) => {
const label = `${step.name} ${step.pathname}`
const pct = Math.round((visitors / maxVisitors) * 100)
const isSelected =
!!selected &&
step.name === selected.name &&
step.pathname === selected.pathname

return (
<li key={label}>
<button
className={`w-full text-left px-4 py-2 text-sm transition-colors focus:outline-none ${
isSelected
? 'bg-indigo-50 dark:bg-indigo-900/30'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => onSelect(isSelected ? null : step)}
>
<div className="flex items-center justify-between mb-1">
<span
className={`truncate font-medium ${
isSelected
? 'text-indigo-700 dark:text-indigo-300'
: 'text-gray-800 dark:text-gray-200'
}`}
title={label}
>
{label}
</span>
<span className="ml-2 shrink-0 text-gray-500 dark:text-gray-400 tabular-nums">
{numberShortFormatter(visitors)}
</span>
</div>
<div className="h-1 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full ${
isSelected
? 'bg-indigo-500'
: 'bg-indigo-300 dark:bg-indigo-600'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</button>
</li>
)
})}
</ul>
)}
</div>
)
}

function columnHeader(index) {
if (index === 0) return 'Start'
return `${index} step${index === 1 ? '' : 's'} after`
}

export function FunnelExploration() {
const { dashboardState } = useDashboardStateContext()
const [steps, setSteps] = useState([])

function handleSelect(columnIndex, selected) {
if (selected === null) {
setSteps(steps.slice(0, columnIndex))
} else {
setSteps([...steps.slice(0, columnIndex), selected])
}
}

const numColumns = Math.max(steps.length + 1, 3)

return (
<div className="p-4">
<h4 className="mt-2 mb-4 text-base font-semibold dark:text-gray-100">
Explore user journeys
</h4>
<div className="flex gap-3">
{Array.from({ length: numColumns }, (_, i) => (
<ExplorationColumn
key={i}
header={columnHeader(i)}
steps={steps.length >= i ? steps.slice(0, i) : null}
selected={steps[i] || null}
onSelect={(selected) => handleSelect(i, selected)}
dashboardState={dashboardState}
/>
))}
</div>
</div>
)
}

export default FunnelExploration
1 change: 1 addition & 0 deletions assets/test-utils/app-context-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const DEFAULT_SITE: PlausibleSite = {
hasGoals: false,
hasProps: false,
funnelsAvailable: false,
explorationAvailable: false,
propsAvailable: false,
siteSegmentsAvailable: false,
conversionsOptedOut: false,
Expand Down
21 changes: 1 addition & 20 deletions extra/lib/plausible/stats/funnel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Plausible.Stats.Funnel do

import Ecto.Query
import Plausible.Stats.SQL.Fragments
import Plausible.Stats.Util, only: [percentage: 2]

alias Plausible.ClickhouseRepo
alias Plausible.Stats.{Base, Query}
Expand Down Expand Up @@ -167,24 +168,4 @@ defmodule Plausible.Stats.Funnel do
|> elem(2)
|> Enum.reverse()
end

defp percentage(x, y) when x in [0, nil] or y in [0, nil] do
"0"
end

defp percentage(x, y) do
result =
x
|> Decimal.div(y)
|> Decimal.mult(100)
|> Decimal.round(2)
|> Decimal.to_string()

case result do
<<compact::binary-size(1), ".00">> -> compact
<<compact::binary-size(2), ".00">> -> compact
<<compact::binary-size(3), ".00">> -> compact
decimal -> decimal
end
end
end
Loading
Loading