List props frontend (#3126)

* add the props section in behaviors

* update listReport when keyLabel (=propKey) changes

* make column min-width configurable and increase for props

* add rendering condition to limit container height

* fix filter link

* fix tests

* disable clear for single-option combobox

* improve single-option combobox styling

* fix fetchPropKeyOptions fn update on query change

* BUGFIX: searching for prop_values in property filter modal

* change the order of funnels and props section pickers

* change props section Bar color from gray to light-red

* remove disabled options from combobox dropdown (multi & single)

* display percentage metric values without a % sign

* change metric labels in goal filter view to Visitors and Events

* fix realtime update timer
This commit is contained in:
RobertJoonas 2023-07-21 11:19:07 +03:00 committed by GitHub
parent b9d122c0c7
commit 9ed79542f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 207 additions and 90 deletions

View File

@ -4,12 +4,10 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import debounce from 'debounce-promise'
import classNames from 'classnames'
function Option({isHighlighted, isDisabled, onClick, onMouseEnter, text, id}) {
const className = classNames('relative select-none py-2 px-3', {
'cursor-pointer': !isDisabled,
'text-gray-300 dark:text-gray-600': isDisabled,
'text-gray-900 dark:text-gray-300': !isDisabled && !isHighlighted,
'bg-indigo-600 text-white': !isDisabled && isHighlighted,
function Option({isHighlighted, onClick, onMouseEnter, text, id}) {
const className = classNames('relative cursor-pointer select-none py-2 px-3', {
'text-gray-900 dark:text-gray-300': !isHighlighted,
'bg-indigo-600 text-white': isHighlighted,
})
return (
@ -25,10 +23,10 @@ function Option({isHighlighted, isDisabled, onClick, onMouseEnter, text, id}) {
}
function scrollTo(wrapper, id) {
if (wrapper) {
const el = wrapper.querySelector('#' + id);
const el = wrapper.querySelector('#' + id)
if (el) {
el.scrollIntoView({block: 'center'});
el.scrollIntoView({block: 'center'})
}
}
}
@ -40,12 +38,12 @@ function optionId(index) {
export default function PlausibleCombobox(props) {
const [options, setOptions] = useState([])
const [loading, setLoading] = useState(false)
const [isOpen, setOpen] = useState(false);
const [input, setInput] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(0);
const searchRef = useRef(null);
const containerRef = useRef(null);
const listRef = useRef(null);
const [isOpen, setOpen] = useState(false)
const [input, setInput] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const searchRef = useRef(null)
const containerRef = useRef(null)
const listRef = useRef(null)
const visibleOptions = [...options]
if (props.freeChoice && input.length > 0 && options.every(option => option.value !== input)) {
@ -108,7 +106,7 @@ export default function PlausibleCombobox(props) {
})
}
const debouncedFetchOptions = useCallback(debounce(fetchOptions, 200), [])
const debouncedFetchOptions = useCallback(debounce(fetchOptions, 200), [fetchOptions])
function onInput(e) {
const newInput = e.target.value
@ -127,8 +125,6 @@ export default function PlausibleCombobox(props) {
}
function selectOption(option) {
if (isDisabled(option)) return
if (props.singleOption) {
props.onSelect([option])
} else {
@ -144,22 +140,20 @@ export default function PlausibleCombobox(props) {
e.stopPropagation()
const newValues = props.values.filter((val) => val.value !== option.value)
props.onSelect(newValues)
if (!searchBoxHidden) {
searchRef.current.focus()
}
searchRef.current.focus()
setOpen(false)
}
const handleClick = useCallback((e) => {
if (containerRef.current && containerRef.current.contains(e.target)) return;
if (containerRef.current && containerRef.current.contains(e.target)) { return }
setInput('')
setOpen(false)
})
useEffect(() => {
document.addEventListener("mousedown", handleClick, false);
return () => { document.removeEventListener("mousedown", handleClick, false); }
document.addEventListener("mousedown", handleClick, false)
return () => { document.removeEventListener("mousedown", handleClick, false) }
}, [])
useEffect(() => {
@ -167,23 +161,47 @@ export default function PlausibleCombobox(props) {
searchRef.current.focus()
}
}, [props.values.length === 0])
const matchesFound = !loading && visibleOptions.length > 0
const noMatchesFound = !loading && visibleOptions.length === 0
const searchBoxHidden = !!props.singleOption && props.values.length === 1
const searchBoxClass = classNames('border-none py-1 px-1 p-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm', {
'hidden': searchBoxHidden
})
const searchBoxClass = 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'
const containerClass = classNames('relative w-full', {
[props.className]: !!props.className,
'opacity-20 cursor-default pointer-events-none': props.isDisabled
})
return (
<div onKeyDown={onKeyDown} ref={containerRef} className={containerClass}>
<div onClick={toggleOpen} className={classNames('pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500', {'border-indigo-500 ring-1 ring-indigo-500': isOpen, '': !isOpen})}>
function renderSingleOptionContent() {
const itemSelected = props.values.length === 1
const placeholder = itemSelected ? '' : props.placeholder
return (
<div className='flex items-center truncate'>
{ itemSelected && renderSingleSelectedItem() }
<input
className={searchBoxClass}
ref={searchRef}
value={input}
style={{backgroundColor: "inherit"}}
placeholder={placeholder}
type="text"
onChange={onInput}>
</input>
</div>
)
}
function renderSingleSelectedItem() {
if (input === '') {
return (
<span className="dark:text-gray-300 text-sm w-0">
{props.values[0].label}
</span>
)
}
}
function renderMultiOptionContent() {
return (
<>
{ props.values.map((value) => {
return (
<div key={value.value} className="bg-indigo-100 dark:bg-indigo-600 flex justify-between w-full rounded-sm px-2 py-0.5 m-0.5 text-sm">
@ -194,12 +212,63 @@ export default function PlausibleCombobox(props) {
})
}
<input className={searchBoxClass} ref={searchRef} value={input} style={{backgroundColor: "inherit"}} placeholder={props.placeholder} type="text" onChange={onInput}></input>
</>
)
}
function renderDropDownContent() {
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isDisabled(option))
if (loading) {
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Loading options...</div>
}
if (matchesFound) {
return visibleOptions
.filter(option => !isDisabled(option))
.map((option, i) => {
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
return (
<Option
key={option.value}
id={optionId(i)}
isHighlighted={highlightedIndex === i}
onClick={() => selectOption(option)}
onMouseEnter={() => setHighlightedIndex(i)}
text={text}
/>
)
})
}
if (props.freeChoice) {
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Start typing to apply filter</div>
}
return (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
No matches found in the current dashboard. Try selecting a different time range or searching for something different
</div>
)
}
const defaultBoxClass = 'pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500'
const boxClass = classNames(props.boxClass || defaultBoxClass, {
'border-indigo-500 ring-1 ring-indigo-500': isOpen,
})
return (
<div onKeyDown={onKeyDown} ref={containerRef} className={containerClass}>
<div onClick={toggleOpen} className={boxClass }>
{props.singleOption && renderSingleOptionContent()}
{!props.singleOption && renderMultiOptionContent()}
<div className="cursor-pointer absolute inset-y-0 right-0 flex items-center pr-2">
{!loading && <ChevronDownIcon className="h-4 w-4 text-gray-500" />}
{loading && <Spinner />}
</div>
</div>
<Transition
{isOpen && <Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
@ -207,42 +276,11 @@ export default function PlausibleCombobox(props) {
show={isOpen}
>
<ul ref={listRef} className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900">
{ loading && (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
Loading options...
</div>
)}
{ noMatchesFound && !props.freeChoice && (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
No matches found in the current dashboard. Try selecting a different time range or searching for something different
</div>
)}
{ noMatchesFound && props.freeChoice && (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
Start typing to apply filter
</div>
)}
{ matchesFound && (
visibleOptions.map((option, i) => {
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
return (
<Option
key={option.value}
id={optionId(i)}
isHighlighted={highlightedIndex === i}
isDisabled={isDisabled(option)}
onClick={() => selectOption(option)}
onMouseEnter={() => setHighlightedIndex(i)}
text={text}
/>
)
})
)}
{ renderDropDownContent() }
</ul>
</Transition>
</Transition>}
</div>
);
)
}
function Spinner() {

View File

@ -20,6 +20,7 @@ if (container) {
funnelsEnabled: container.dataset.funnelsEnabled === 'true',
propsEnabled: container.dataset.propsEnabled === 'true',
funnels: JSON.parse(container.dataset.funnels),
allowedEventProps: JSON.parse(container.dataset.allowedEventProps),
statsBegin: container.dataset.statsBegin,
nativeStatsBegin: container.dataset.nativeStatsBegin,
embedded: container.dataset.embedded,

View File

@ -5,6 +5,7 @@ import classNames from 'classnames'
import * as storage from '../../util/storage'
import Conversions from './conversions'
import Properties from './props'
import Funnel from './funnel'
import { FeatureSetupNotice } from '../../components/notice'
@ -12,13 +13,13 @@ const ACTIVE_CLASS = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
export const CONVERSIONS = 'conversions'
export const FUNNELS = 'funnels'
export const PROPS = 'props'
export const FUNNELS = 'funnels'
export const sectionTitles = {
[CONVERSIONS]: 'Goal Conversions',
[FUNNELS]: 'Funnels',
[PROPS]: 'Custom Properties'
[PROPS]: 'Custom Properties',
[FUNNELS]: 'Funnels'
}
export default function Behaviours(props) {
@ -115,8 +116,8 @@ export default function Behaviours(props) {
return (
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
{isEnabled(FUNNELS) && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
{isEnabled(FUNNELS) && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
</div>
)
}
@ -158,7 +159,9 @@ export default function Behaviours(props) {
}
function renderProps() {
if (adminAccess) {
if (site.allowedEventProps && site.allowedEventProps.length > 0) {
return <Properties site={site} query={props.query} allowedEventProps={site.allowedEventProps}/>
} else if (adminAccess) {
return (
<FeatureSetupNotice
site={site}
@ -189,10 +192,10 @@ export default function Behaviours(props) {
switch (mode) {
case CONVERSIONS:
return renderConversions()
case FUNNELS:
return renderFunnels()
case PROPS:
return renderProps()
case FUNNELS:
return renderFunnels()
}
}
@ -203,8 +206,8 @@ export default function Behaviours(props) {
if (storedMode && enabledModes.includes(storedMode)) { return storedMode }
if (enabledModes.includes(CONVERSIONS)) { return CONVERSIONS }
if (enabledModes.includes(FUNNELS)) { return FUNNELS }
return PROPS
if (enabledModes.includes(PROPS)) { return PROPS }
return FUNNELS
}
function getEnabledModes() {
@ -213,12 +216,12 @@ export default function Behaviours(props) {
if (site.conversionsEnabled) {
enabledModes.push(CONVERSIONS)
}
if (site.propsEnabled && site.flags.props) {
enabledModes.push(PROPS)
}
if (site.funnelsEnabled && !isRealtime() && site.flags.funnels) {
enabledModes.push(FUNNELS)
}
if (site.propsEnabled && !isRealtime() && site.flags.props) {
enabledModes.push(PROPS)
}
return enabledModes
}

View File

@ -0,0 +1,69 @@
import React, { useCallback, useState } from "react";
import ListReport from "../reports/list";
import Combobox from '../../components/combobox'
import * as api from '../../api'
import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage";
export default function Properties(props) {
const { site, query } = props
const propKeyStorageName = `prop_key__${site.domain}`
const [propKey, setPropKey] = useState(defaultPropKey())
function defaultPropKey() {
const stored = storage.getItem(propKeyStorageName)
if (stored) { return stored }
return null
}
function fetchProps() {
return api.get(url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), query)
}
const fetchPropKeyOptions = useCallback(() => {
return (input) => {
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
}
}, [query])
function onPropKeySelect() {
return (selectedOptions) => {
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value
if (newPropKey) { storage.setItem(propKeyStorageName, newPropKey) }
setPropKey(newPropKey)
}
}
function renderBreakdown() {
return (
<ListReport
fetchData={fetchProps}
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={[
{name: 'visitors', label: 'Visitors'},
{name: 'events', label: 'Events'},
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC
]}
query={query}
color="bg-red-50"
colMinWidth={90}
/>
)
}
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: listItem['name']})} }
const comboboxValues = propKey ? [{value: propKey, label: propKey}] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
return (
<div className="w-full mt-4">
<div className="w-56">
<Combobox boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div>
{ propKey && renderBreakdown() }
</div>
)
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { withRouter } from "react-router-dom";
import Combobox from '../../components/combobox'
@ -38,13 +38,13 @@ function PropFilterModal(props) {
}
}
function fetchPropValueOptions() {
const fetchPropValueOptions = useCallback(() => {
return (input) => {
const propKey = formState.prop_key?.value
const updatedQuery = { ...query, filters: { ...query.filters, props: {[propKey]: '!(none)'} } }
return api.get(apiPath(props.site, "/suggestions/prop_value"), updatedQuery, { q: input.trim() })
}
}
}, [formState.prop_key])
function onPropKeySelect() {
return (selectedOptions) => {

View File

@ -106,6 +106,7 @@ export default function ListReport(props) {
const [state, setState] = useState({loading: true, list: null})
const [visible, setVisible] = useState(false)
const metrics = props.metrics
const colMinWidth = props.colMinWidth || COL_MIN_WIDTH
const isRealtime = props.query.period === 'realtime'
const goalFilterApplied = !!props.query.filters.goal
@ -116,7 +117,7 @@ export default function ListReport(props) {
}
props.fetchData()
.then((res) => setState({loading: false, list: res}))
}, [props.query])
}, [props.keyLabel, props.query])
const onVisible = () => { setVisible(true) }
@ -136,7 +137,7 @@ export default function ListReport(props) {
}
return () => { document.removeEventListener('tick', fetchData) }
}, [props.query, visible]);
}, [props.keyLabel, props.query, visible]);
function renderReport() {
if (state.list && state.list.length > 0) {
@ -159,7 +160,7 @@ export default function ListReport(props) {
function renderReportHeader() {
const metricLabels = metrics.map((metric) => {
return (<span key={metric.name} className="text-right" style={{minWidth: COL_MIN_WIDTH}}>{ metricLabelFor(metric, props.query) }</span>)
return (<span key={metric.name} className="text-right" style={{minWidth: colMinWidth}}>{ metricLabelFor(metric, props.query) }</span>)
})
return (
@ -242,7 +243,7 @@ export default function ListReport(props) {
function renderMetricValuesFor(listItem) {
return metrics.map((metric) => {
return (
<div key={`${listItem.name}__${metric.name}`} style={{width: COL_MIN_WIDTH, minWidth: COL_MIN_WIDTH}} className="text-right">
<div key={`${listItem.name}__${metric.name}`} style={{width: colMinWidth, minWidth: colMinWidth}} className="text-right">
<span className="font-medium text-sm dark:text-gray-200 text-right">
{ displayMetricValue(listItem[metric.name], metric) }
</span>
@ -275,9 +276,9 @@ export default function ListReport(props) {
<LazyLoader onVisible={onVisible} >
<div className="w-full" style={{minHeight: `${MIN_HEIGHT}px`}}>
{ state.loading && renderLoading() }
<FadeIn show={!state.loading} className="h-full">
{ !state.loading && <FadeIn show={!state.loading} className="h-full">
{ renderReport() }
</FadeIn>
</FadeIn> }
</div>
</LazyLoader>
)

View File

@ -33,7 +33,9 @@ export function maybeWithCR(metrics, query) {
}
export function displayMetricValue(value, metric) {
if ([PERCENTAGE_METRIC, CR_METRIC].includes(metric)) {
if (metric === PERCENTAGE_METRIC) {
return value
} else if (metric === CR_METRIC) {
return `${value}%`
} else {
return <span tooltip={value}>{ numberFormatter(value) }</span>

View File

@ -65,6 +65,7 @@ defmodule PlausibleWeb.StatsController do
site: site,
has_goals: Plausible.Sites.has_goals?(site),
funnels: Plausible.Funnels.list(site),
allowed_event_props: site.allowed_event_props,
stats_start_date: stats_start_date,
native_stats_start_date: NaiveDateTime.to_date(site.native_stats_start_at),
title: title(conn, site),
@ -299,6 +300,7 @@ defmodule PlausibleWeb.StatsController do
site: shared_link.site,
has_goals: Sites.has_goals?(shared_link.site),
funnels: Plausible.Funnels.list(shared_link.site),
allowed_event_props: shared_link.site.allowed_event_props,
stats_start_date: shared_link.site.stats_start_date,
native_stats_start_date: NaiveDateTime.to_date(shared_link.site.native_stats_start_at),
title: title(conn, shared_link.site),

View File

@ -22,6 +22,7 @@
data-funnels-enabled="<%= @site.funnels_enabled %>"
data-props-enabled="<%= @site.props_enabled %>"
data-funnels="<%= Jason.encode!(@funnels) %>"
data-allowed-event-props="<%= Jason.encode!(@allowed_event_props) %>"
data-logged-in="<%= !!@conn.assigns[:current_user] %>"
data-stats-begin="<%= @stats_start_date %>"
data-native-stats-begin="<%= @native_stats_start_date %>"