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
1,155 changes: 0 additions & 1,155 deletions assets/js/dashboard/extra/exploration.js

This file was deleted.

11 changes: 11 additions & 0 deletions assets/js/dashboard/extra/exploration/constants.ts
Original file line number Diff line number Diff line change
@@ -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
382 changes: 382 additions & 0 deletions assets/js/dashboard/extra/exploration/exploration-column.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div ref={containerRef} className="relative shrink-0">
<button
data-testid={`exploration-direction-${direction}`}
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-0.5 text-xs font-semibold text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-200"
>
{currentLabel}
<ChevronUpDownIcon className="size-3.5 shrink-0" />
</button>

{open && (
<div
className={`absolute -left-1 top-full mt-1 z-10 min-w-40 dark:!bg-gray-900 ${popover.panel.classNames.roundedSheet}`}
>
{DIRECTION_OPTIONS.map(({ value, label }) => (
<button
key={value}
data-testid={`exploration-direction-${value}`}
data-selected={direction === value}
onClick={() => {
onChange(value)
setOpen(false)
}}
className={`w-full text-left text-sm rounded-md dark:hover:!bg-gray-750 data-[selected=true]:dark:!bg-gray-750 ${popover.items.classNames.navigationLink} ${popover.items.classNames.hoverLink} ${direction === value ? popover.items.classNames.selectedOption : ''}`}
>
{label}
</button>
))}
</div>
)}
</div>
)
}

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 ? (
<CursorIcon className={iconClassName} />
) : step.includes_subpaths ? (
<FolderIcon className={iconClassName} />
) : null

const iconElement = !iconSvg ? null : (
<Tooltip info={iconTooltipInfo} containerRef={{ current: document.body }}>
{iconSvg}
</Tooltip>
)

return (
<li data-testid="exploration-row">
<button
data-exploration-step={isSelected ? colIndex : undefined}
className={`group relative w-full text-left text-sm rounded-sm overflow-hidden focus:outline-none ${rowBg} ${pointer}`}
onClick={() => onSelectHandler(isSelected ? null : step)}
>
<div
className={`absolute top-0 left-0 h-full rounded-sm transition-[width] ease-in-out ${barBg}`}
data-testid="metric-bar"
style={{ width: `${barWidth}%` }}
/>

<div className="relative flex items-center justify-between gap-2 px-2 py-1.5">
<span
className={`flex items-center gap-2 min-w-0 ${textColor}`}
title={step.label}
data-testid="metric-label"
>
{iconElement}
<span className="truncate">{step.label}</span>
</span>

<span className={`shrink-0 font-medium ${textColor}`}>
<VisitorsMetric visitors={visitorsToShow} />
</span>
</div>
</button>
</li>
)
}

function VisitorsMetric({ visitors }) {
const shortNumber = numberShortFormatter(visitors)
const longNumber = numberLongFormatter(visitors)
const showTooltip = shortNumber !== longNumber

if (showTooltip) {
return (
<Tooltip info={longNumber} containerRef={{ current: document.body }}>
<span data-testid="metric-value">{shortNumber}</span>
</Tooltip>
)
} else {
return <span data-testid="metric-value">{shortNumber}</span>
}
}

function ColumnEmptyState({
active,
filter,
colIndex,
direction,
rateLimited,
onRetry
}) {
if (active && rateLimited) {
return (
<span>
Too many requests, please wait a moment and{' '}
<button
onClick={onRetry}
className="underline hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none"
>
try again
</button>
</span>
)
}

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 (
<span className="flex flex-col items-center gap-2">
<CursorIcon className="size-5" />
{prompt}
</span>
)
}

if (filter) {
return (
<span className="flex flex-col items-center gap-2">
<MagnifyingGlassIcon className="size-4.5" />
No events found
</span>
)
}

return (
<span className="flex flex-col items-center gap-2">
<FlagIcon className="size-4.5" />
No further steps found for the selected period and filters
</span>
)
}

export function MaxDepthColumn({ colIndex, header }) {
const { explorationMaxJourneySteps: maxJourneySteps } = useSiteContext()
return (
<div
data-testid={`exploration-column-${colIndex}`}
data-exploration-column={colIndex}
className="border border-gray-200 dark:border-gray-750 rounded-lg overflow-hidden"
>
<div className="h-[42px] py-2 pl-4 pr-1.5 flex items-center">
<span className="shrink-0 text-xs font-semibold text-gray-900 dark:text-gray-100">
{header}
</span>
</div>
<div className="h-92 flex items-center justify-center max-w-2/3 mx-auto text-center text-sm text-pretty text-gray-400 dark:text-gray-500">
<span className="flex flex-col items-center gap-2">
<FlagIcon className="size-4.5" />
{`You've reached the maximum journey depth of ${maxJourneySteps} steps.`}
</span>
</div>
</div>
)
}

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 (
<div
data-testid={`exploration-column-${colIndex}`}
data-exploration-column={colIndex}
className="border border-gray-200 dark:border-gray-750 rounded-lg overflow-hidden"
>
<div className="h-[44px] py-1.5 pl-4 pr-1.5 flex items-center justify-between gap-x-2">
{onDirectionChange ? (
<DirectionDropdown
direction={direction}
onChange={onDirectionChange}
/>
) : (
<span className="shrink-0 text-xs font-semibold text-gray-900 dark:text-gray-100">
{header}
</span>
)}

{showSearch && (
<input
data-testid="search-input"
type="text"
defaultValue={filter}
placeholder="Search"
onChange={debouncedFilterChange}
className="peer max-w-48 w-full h-full py-0 text-xs dark:text-gray-100 block border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-700 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"
/>
)}

{!showSearch && headerConversionRate && (
<span className="shrink-0 text-xs font-semibold text-gray-900 dark:text-gray-100">
{headerConversionRate}
</span>
)}
</div>

{loading ? (
<div className="h-92 flex items-center justify-center">
<div className="mx-auto loading pt-4">
<div></div>
</div>
</div>
) : listItems.length === 0 ? (
<div className="h-92 flex items-center justify-center max-w-2/3 mx-auto text-center text-sm text-pretty text-gray-400 dark:text-gray-500">
<ColumnEmptyState
active={active}
filter={filter}
colIndex={colIndex}
direction={direction}
rateLimited={rateLimited}
onRetry={onRetry}
/>
</div>
) : (
<ul
data-exploration-list
className="flex flex-col gap-y-1 px-2 pb-2 h-92 overflow-y-auto [scrollbar-width:thin] [scrollbar-color:theme(colors.gray.300)_transparent] dark:[scrollbar-color:theme(colors.gray.600)_transparent]"
>
{listItems.map(({ step, visitors }) => (
<CandidateCard
key={`${step.name}:${step.label}:${step.includes_subpaths ? step.subpaths_count : 0}`}
step={step}
visitors={visitors}
isSelected={!!selected && journeyStepsEqual(step, selected)}
isDimmed={!!selected && !journeyStepsEqual(step, selected)}
selectedVisitors={selectedVisitors}
selectedConversionRate={selectedConversionRate}
stepMaxVisitors={stepMaxVisitors}
colIndex={colIndex}
onSelect={onSelectHandler}
/>
))}
</ul>
)}
</div>
)
}
Loading
Loading