mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
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:
parent
b9d122c0c7
commit
9ed79542f2
@ -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() {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
69
assets/js/dashboard/stats/behaviours/props.js
Normal file
69
assets/js/dashboard/stats/behaviours/props.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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) => {
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
|
@ -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 %>"
|
||||
|
Loading…
Reference in New Issue
Block a user