Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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