Skip to content
Closed
183 changes: 183 additions & 0 deletions assets/js/dashboard/extra/funnel-exploration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, { useState, useEffect } from 'react'
import * as api from '../api'
import { useDashboardStateContext } from '../dashboard-state-context'
import { useSiteContext } from '../site-context'
import { createStatsQuery } from '../stats-query'
import { numberShortFormatter } from '../util/number-formatter'

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

function fetchColumnData(site, dashboardState, steps) {
// 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 query = createStatsQuery(stateToUse, {
dimensions: ['event:label'],
metrics: ['visitors']
})

if (steps.length > 0) {
const seqFilter = ['sequence', steps.map((s) => ['is', 'event:label', [s]])]
query.filters = [...query.filters, seqFilter]
}

return api.stats(site, query)
}

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

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

setLoading(true)
setResults([])

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

const maxVisitors = results.length > 0 ? results[0].metrics[0] : 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 && (
<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(({ dimensions }) => dimensions[0] === selected) : results.slice(0, 10)).map(({ dimensions, metrics }) => {
const label = dimensions[0]
const visitors = metrics[0]
const pct = Math.round((visitors / maxVisitors) * 100)
const isSelected = selected === label

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 : label)}
>
<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, label) {
if (label === null) {
setSteps(steps.slice(0, columnIndex))
} else {
setSteps([...steps.slice(0, columnIndex), label])
}
}

// Show 3 columns by default; add a new column each time the last column gets a selection
const numColumns = Math.max(3, steps.length + 1)

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={(label) => handleSelect(i, label)}
dashboardState={dashboardState}
/>
))}
</div>
</div>
)
}

export default FunnelExploration
4 changes: 3 additions & 1 deletion assets/js/dashboard/site-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
}

// Update this object when new feature flags are added to the frontend.
type FeatureFlags = Record<never, boolean>
type FeatureFlags = {
funnel_exploration?: boolean
}

export const siteContextDefaultValue = {
domain: '',
Expand Down
55 changes: 38 additions & 17 deletions assets/js/dashboard/stats/behaviours/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,14 @@ import { getSpecialGoal, isPageViewGoal, isSpecialGoal } from '../../util/goals'

/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../extra/funnel')
} else {
return { default: null }
}
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Funnel = BUILD_EXTRA ? require('../../extra/funnel').default : null
// eslint-disable-next-line @typescript-eslint/no-require-imports
const FunnelExploration = BUILD_EXTRA
? (require('../../extra/funnel-exploration').FunnelExploration ?? null)
: null

const Funnel = maybeRequire().default
const EXPLORE_MODE = '__explore__'

function singleGoalFilterApplied(dashboardState) {
const goalFilter = getGoalFilter(dashboardState)
Expand Down Expand Up @@ -94,12 +92,21 @@ function storePropKey({ site, propKey, dashboardState }) {
}
}

const funnelExplorationAvailable = (site) =>
FunnelExploration !== null && site.flags.funnel_exploration

function getDefaultSelectedFunnel({ site }) {
const stored = storage.getItem(STORAGE_KEYS.getForFunnel({ site }))
const storedExists = stored && site.funnels.some((f) => f.name === stored)
const storedExists =
stored === EXPLORE_MODE
? funnelExplorationAvailable(site)
: stored && site.funnels.some((f) => f.name === stored)

if (storedExists) {
return stored
} else if (funnelExplorationAvailable(site)) {
storage.setItem(STORAGE_KEYS.getForFunnel({ site }), EXPLORE_MODE)
return EXPLORE_MODE
} else if (site.funnels.length > 0) {
const firstAvailable = site.funnels[0].name
storage.setItem(STORAGE_KEYS.getForFunnel({ site }), firstAvailable)
Expand Down Expand Up @@ -291,7 +298,9 @@ function Behaviours({ importedDataInView, setMode, mode }) {
}

function renderFunnels() {
if (Funnel === null) {
if (selectedFunnel === EXPLORE_MODE && funnelExplorationAvailable(site)) {
return <FunnelExploration />
} else if (Funnel === null) {
return featureUnavailable()
} else if (Funnel && selectedFunnel && site.funnelsAvailable) {
return <Funnel funnelName={selectedFunnel} />
Expand Down Expand Up @@ -496,16 +505,28 @@ function Behaviours({ importedDataInView, setMode, mode }) {
{!site.isConsolidatedView &&
isEnabled(Mode.FUNNELS) &&
Funnel &&
(site.funnels.length > 0 && site.funnelsAvailable ? (
(site.funnels.length > 0 && site.funnelsAvailable ||
funnelExplorationAvailable(site) ? (
<DropdownTabButton
className="md:relative"
transitionClassName="md:left-auto md:w-88 md:origin-top-right"
active={mode === Mode.FUNNELS}
options={site.funnels.map(({ name }) => ({
label: name,
onClick: setFunnelFactory(name),
selected: mode === Mode.FUNNELS && selectedFunnel === name
}))}
options={[
...(funnelExplorationAvailable(site)
? [
{
label: 'Explore',
onClick: setFunnelFactory(EXPLORE_MODE),
selected: mode === Mode.FUNNELS && selectedFunnel === EXPLORE_MODE
}
]
: []),
...site.funnels.map(({ name }) => ({
label: name,
onClick: setFunnelFactory(name),
selected: mode === Mode.FUNNELS && selectedFunnel === name
}))
]}
searchable={true}
>
Funnels
Expand Down
8 changes: 7 additions & 1 deletion assets/js/types/query-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type SimpleFilterDimensions =
| "event:name"
| "event:page"
| "event:hostname"
| "event:label"
| "visit:source"
| "visit:channel"
| "visit:referrer"
Expand Down Expand Up @@ -70,7 +71,7 @@ export type SimpleFilterDimensions =
export type CustomPropertyFilterDimensions = string;
export type GoalDimension = "event:goal";
export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone;
export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone | FilterSequence;
export type FilterEntry = FilterWithoutGoals | FilterWithIs | FilterWithContains | FilterWithPattern;
/**
* @minItems 3
Expand Down Expand Up @@ -147,6 +148,11 @@ export type FilterNot = ["not", FilterTree];
* @maxItems 2
*/
export type FilterHasDone = ["has_done" | "has_not_done", FilterTree];
/**
* @minItems 2
* @maxItems 2
*/
export type FilterSequence = ["sequence", [FilterTree, ...FilterTree[]]];
/**
* @minItems 2
* @maxItems 2
Expand Down
9 changes: 5 additions & 4 deletions lib/plausible/stats/api_query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,14 @@ defmodule Plausible.Stats.ApiQueryParser do
defp parse_operator(["not" | _rest]), do: {:ok, :not}
defp parse_operator(["has_done" | _rest]), do: {:ok, :has_done}
defp parse_operator(["has_not_done" | _rest]), do: {:ok, :has_not_done}
defp parse_operator(["sequence" | _rest]), do: {:ok, :sequence}

defp parse_operator(filter),
do:
{:error,
%QueryError{code: :invalid_filters, message: "Unknown operator for filter '#{i(filter)}'."}}

def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or],
def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or, :sequence],
do: parse_filters(filters)

def parse_filter_second(operator, [_, filter | _rest])
Expand Down Expand Up @@ -127,7 +128,7 @@ defmodule Plausible.Stats.ApiQueryParser do
end

defp parse_filter_rest(operator, _filter)
when operator in [:not, :and, :or, :has_done, :has_not_done],
when operator in [:not, :and, :or, :has_done, :has_not_done, :sequence],
do: {:ok, []}

defp parse_clauses_list([operator, dimension, list | _rest] = filter) when is_list(list) do
Expand Down Expand Up @@ -221,14 +222,14 @@ defmodule Plausible.Stats.ApiQueryParser do
end
end

defp parse_dimensions(dimensions) when is_list(dimensions) do
def parse_dimensions(dimensions) when is_list(dimensions) do
parse_list(
dimensions,
&parse_dimension_entry(&1, "Invalid dimensions '#{i(dimensions)}'")
)
end

defp parse_dimensions(nil), do: {:ok, []}
def parse_dimensions(nil), do: {:ok, []}

def parse_order_by(order_by) when is_list(order_by) do
parse_list(order_by, &parse_order_by_entry/1)
Expand Down
2 changes: 2 additions & 0 deletions lib/plausible/stats/dashboard/query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ defmodule Plausible.Stats.Dashboard.QueryParser do
with {:ok, input_date_range} <- parse_input_date_range(params),
{:ok, relative_date} <- parse_relative_date(params),
{:ok, filters} <- parse_filters(params),
{:ok, dimensions} <- ApiQueryParser.parse_dimensions(params["dimensions"]),
{:ok, metrics} <- parse_metrics(params),
{:ok, include} <- parse_include(params) do
{:ok,
ParsedQueryParams.new!(%{
input_date_range: input_date_range,
relative_date: relative_date,
filters: filters,
dimensions: dimensions,
metrics: metrics,
include: include,
skip_goal_existence_check: true
Expand Down
Loading
Loading