Implement search in Details views (#4318)

* Create a new BreakdownModal component and use it for Entry Pages

* Add search functionality into the new component

* Adjust FilterLink component and use it in BreakdownModal

* pass addSearchFilter fn through props

* pass fn props as useCallback

* add a function doc to BreakdownModal

* refactor: create a Metric class

* Fixup: use Metric class for defining BreakdownModal metrics

* keep revenueAvailable state in the Dashboard component

* move query context into a higher-order component

* fix react key error in BreakdownModal

* use BreakdownModal in PropsModal

* adjust EntryPagesModal to use query context

* fix variable name typo

* fixup: BreakdownModal function doc

* use BreakdownModal in SourcesModal

* use Breakdown modal in ReferrerDrilldownModal

* use BreakdownModal in PagesModal

* use BreakdownModal in ExitPagesModal

* replace ModalTable with LocationsModal and use BreakdownModal in it

* use BreakdownModal in Conversions

* make sure next pages are loaded with 'detailed: true'

* replace loading spinner logic in BreakdownModal

* fix two flaky tests

* unfocus search input element on Escape keyup event

* ignore Escape keyup handling when search disabled

* Review suggestion: remove redundant state

* do not fetch data on every search input change

* use longer variable names

* do not define renderIcon callbacks conditionally

* deconstruct props in function header of BreakdownModal

* refactor searchEnabled being true by default
This commit is contained in:
RobertJoonas 2024-07-09 15:01:52 +03:00 committed by GitHub
parent a9676546dc
commit 7d0321fd22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1262 additions and 1252 deletions

View File

@ -0,0 +1,50 @@
import React, { useState, useEffect} from "react"
import * as api from '../api'
import { useMountedEffect } from '../custom-hooks'
import { parseQuery } from "../query"
// A Higher-Order component that tracks `query` state, and additional context
// related to it, such as:
// * `importedDataInView` - simple state with a `false` default. An
// `updateImportedDataInView` prop will be passed into the WrappedComponent
// and allows changing that according to responses from the API.
// * `lastLoadTimestamp` - used for displaying a tooltip with time passed since
// the last update in realtime components.
export default function withQueryContext(WrappedComponent) {
return (props) => {
const { site, location } = props
const [query, setQuery] = useState(parseQuery(location.search, site))
const [importedDataInView, setImportedDataInView] = useState(false)
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) }
useEffect(() => {
document.addEventListener('tick', updateLastLoadTimestamp)
return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [])
useMountedEffect(() => {
api.cancelAll()
setQuery(parseQuery(location.search, site))
updateLastLoadTimestamp()
}, [location.search])
return (
<WrappedComponent
{...props}
query={query}
importedDataInView={importedDataInView}
updateImportedDataInView={setImportedDataInView}
lastLoadTimestamp={lastLoadTimestamp}
/>
)
}
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
// A custom hook that behaves like `useEffect`, but // A custom hook that behaves like `useEffect`, but
// the function does not run on the initial render. // the function does not run on the initial render.
@ -12,4 +12,16 @@ export function useMountedEffect(fn, deps) {
mounted.current = true mounted.current = true
} }
}, deps) }, deps)
}
// A custom hook that debounces the function calls by
// a given delay. Cancels all function calls that have
// a following call within `delay_ms`.
export function useDebouncedEffect(fn, deps, delay_ms) {
const callback = useCallback(fn, deps)
useEffect(() => {
const timeout = setTimeout(callback, delay_ms)
return () => clearTimeout(timeout)
}, [callback, delay_ms])
} }

View File

@ -1,35 +1,22 @@
import React, { useEffect, useState } from 'react'; import React from 'react'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import { useMountedEffect } from './custom-hooks';
import Historical from './historical' import Historical from './historical'
import Realtime from './realtime' import Realtime from './realtime'
import {parseQuery} from './query' import withQueryContext from './components/query-context-hoc';
import * as api from './api'
export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded" export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded"
function Dashboard(props) { function Dashboard(props) {
const { location, site, loggedIn, currentUserRole } = props const {
const [query, setQuery] = useState(parseQuery(location.search, site)) site,
const [importedDataInView, setImportedDataInView] = useState(false) loggedIn,
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) currentUserRole,
const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } query,
importedDataInView,
useEffect(() => { updateImportedDataInView,
document.addEventListener('tick', updateLastLoadTimestamp) lastLoadTimestamp
} = props
return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [])
useMountedEffect(() => {
api.cancelAll()
setQuery(parseQuery(location.search, site))
updateLastLoadTimestamp()
}, [location.search])
if (query.period === 'realtime') { if (query.period === 'realtime') {
return ( return (
@ -50,10 +37,10 @@ function Dashboard(props) {
query={query} query={query}
lastLoadTimestamp={lastLoadTimestamp} lastLoadTimestamp={lastLoadTimestamp}
importedDataInView={importedDataInView} importedDataInView={importedDataInView}
updateImportedDataInView={setImportedDataInView} updateImportedDataInView={updateImportedDataInView}
/> />
) )
} }
} }
export default withRouter(Dashboard) export default withRouter(withQueryContext(Dashboard))

View File

@ -5,6 +5,7 @@ import { PlausibleSearchParams, updatedQuery } from './util/url'
import { nowForSite } from './util/date' import { nowForSite } from './util/date'
import * as storage from './util/storage' import * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input' import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
import { getFiltersByKeyPrefix } from './util/filters'
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
@ -47,6 +48,10 @@ export function parseQuery(querystring, site) {
} }
} }
export function addFilter(query, filter) {
return {...query, filters: [...query.filters, filter]}
}
export function navigateToQuery(history, queryFrom, newData) { export function navigateToQuery(history, queryFrom, newData) {
// if we update any data that we store in localstorage, make sure going back in history will // if we update any data that we store in localstorage, make sure going back in history will
// revert them // revert them
@ -133,6 +138,26 @@ export function filtersBackwardsCompatibilityRedirect() {
} }
} }
// Returns a boolean indicating whether the given query includes a
// non-empty goal filterset containing a single, or multiple revenue
// goals with the same currency. Used to decide whether to render
// revenue metrics in a dashboard report or not.
export function revenueAvailable(query, site) {
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
const goalFilters = getFiltersByKeyPrefix(query, "goal")
return goalFilters.some(([_op, _key, clauses]) => {
return clauses.includes(rg.event_name)
})
})
const singleCurrency = revenueGoalsInFilter.every((rg) => {
return rg.currency === revenueGoalsInFilter[0].currency
})
return revenueGoalsInFilter.length > 0 && singleCurrency
}
function QueryLink(props) { function QueryLink(props) {
const { query, history, to, className, children } = props const { query, history, to, className, children } = props

View File

@ -28,7 +28,7 @@ function Realtime(props) {
<Datepicker site={site} query={query} /> <Datepicker site={site} query={query} />
</div> </div>
</div> </div>
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp} /> <VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
<div className="w-full md:flex"> <div className="w-full md:flex">
<div className={ statsBoxClass }> <div className={ statsBoxClass }>
<Sources site={site} query={query} /> <Sources site={site} query={query} />

View File

@ -8,11 +8,10 @@ import GoogleKeywordsModal from './stats/modals/google-keywords'
import PagesModal from './stats/modals/pages' import PagesModal from './stats/modals/pages'
import EntryPagesModal from './stats/modals/entry-pages' import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages' import ExitPagesModal from './stats/modals/exit-pages'
import ModalTable from './stats/modals/table' import LocationsModal from './stats/modals/locations-modal';
import PropsModal from './stats/modals/props' import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions' import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal' import FilterModal from './stats/modals/filter-modal'
import * as url from './util/url';
function ScrollToTop() { function ScrollToTop() {
const location = useLocation(); const location = useLocation();
@ -51,14 +50,8 @@ export default function Router({ site, loggedIn, currentUserRole }) {
<Route path="/exit-pages"> <Route path="/exit-pages">
<ExitPagesModal site={site} /> <ExitPagesModal site={site} />
</Route> </Route>
<Route path="/countries"> <Route exact path={["/countries", "/regions", "/cities"]}>
<ModalTable title="Top countries" site={site} endpoint={url.apiPath(site, '/countries')} filterKey="country" keyLabel="Country" renderIcon={renderCountryIcon} showPercentage={true} /> <LocationsModal site={site} />
</Route>
<Route path="/regions">
<ModalTable title="Top regions" site={site} endpoint={url.apiPath(site, '/regions')} filterKey="region" keyLabel="Region" renderIcon={renderRegionIcon} />
</Route>
<Route path="/cities">
<ModalTable title="Top cities" site={site} endpoint={url.apiPath(site, '/cities')} filterKey="city" keyLabel="City" renderIcon={renderCityIcon} />
</Route> </Route>
<Route path="/custom-prop-values/:prop_key"> <Route path="/custom-prop-values/:prop_key">
<PropsModal site={site} /> <PropsModal site={site} />
@ -74,15 +67,3 @@ export default function Router({ site, loggedIn, currentUserRole }) {
</BrowserRouter > </BrowserRouter >
); );
} }
function renderCityIcon(city) {
return <span className="mr-1">{city.country_flag}</span>
}
function renderCountryIcon(country) {
return <span className="mr-1">{country.flag}</span>
}
function renderRegionIcon(region) {
return <span className="mr-1">{region.country_flag}</span>
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import * as api from '../../api' import * as api from '../../api'
import * as url from '../../util/url' import * as url from '../../util/url'
import { CR_METRIC } from '../reports/metrics'; import * as metrics from '../reports/metrics';
import ListReport from '../reports/list'; import ListReport from '../reports/list';
export default function Conversions(props) { export default function Conversions(props) {
@ -19,6 +19,16 @@ export default function Conversions(props) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Total", meta: {hiddenOnMobile: true}}),
metrics.createConversionRate(),
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
].filter(metric => !!metric)
}
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
return ( return (
<ListReport <ListReport
@ -27,13 +37,7 @@ export default function Conversions(props) {
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Goal" keyLabel="Goal"
onClick={props.onGoalFilterClick} onClick={props.onGoalFilterClick}
metrics={[ metrics={chooseMetrics()}
{ name: 'visitors', label: "Uniques", plot: true },
{ name: 'events', label: "Total", hiddenOnMobile: true },
CR_METRIC,
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
detailsLink={url.sitePath('conversions')} detailsLink={url.sitePath('conversions')}
maybeHideDetails={true} maybeHideDetails={true}
query={query} query={query}

View File

@ -1,7 +1,7 @@
import React from "react" import React from "react"
import Conversions from './conversions' import Conversions from './conversions'
import ListReport from "../reports/list" import ListReport from "../reports/list"
import { CR_METRIC } from "../reports/metrics" import * as metrics from '../reports/metrics'
import * as url from "../../util/url" import * as url from "../../util/url"
import * as api from "../../api" import * as api from "../../api"
import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters" import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters"
@ -53,17 +53,21 @@ function SpecialPropBreakdown(props) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
metrics.createConversionRate()
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel={prop} keyLabel={prop}
metrics={[ metrics={chooseMetrics()}
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
CR_METRIC
]}
detailsLink={url.sitePath(`custom-prop-values/${prop}`)} detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
externalLinkDest={externalLinkDest()} externalLinkDest={externalLinkDest()}
maybeHideDetails={true} maybeHideDetails={true}

View File

@ -1,9 +1,9 @@
import React, { useCallback, useEffect, useState } from "react" import React, { useCallback, useEffect, useState } from "react"
import ListReport, { MIN_HEIGHT } from "../reports/list"; import ListReport, { MIN_HEIGHT } from "../reports/list";
import Combobox from '../../components/combobox' import Combobox from '../../components/combobox'
import * as metrics from '../reports/metrics'
import * as api from '../../api' import * as api from '../../api'
import * as url from '../../util/url' import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage"; import * as storage from "../../util/storage";
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters" import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters"
import classNames from "classnames"; import classNames from "classnames";
@ -82,8 +82,19 @@ export default function Properties(props) {
setPropKey(newPropKey) setPropKey(newPropKey)
} }
} }
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage(),
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
].filter(metric => !!metric)
}
function renderBreakdown() { function renderBreakdown() {
return ( return (
<ListReport <ListReport
@ -91,13 +102,7 @@ export default function Properties(props) {
afterFetchData={props.afterFetchData} afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel={propKey} keyLabel={propKey}
metrics={[ metrics={chooseMetrics()}
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
hasGoalFilter(query) ? CR_METRIC : PERCENTAGE_METRIC,
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)} detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
maybeHideDetails={true} maybeHideDetails={true}
query={query} query={query}

View File

@ -1,10 +1,10 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import * as storage from '../../util/storage' import * as storage from '../../util/storage'
import { getFiltersByKeyPrefix, isFilteringOnFixedValue } from '../../util/filters' import { getFiltersByKeyPrefix, hasGoalFilter, isFilteringOnFixedValue } from '../../util/filters'
import ListReport from '../reports/list' import ListReport from '../reports/list'
import * as metrics from '../reports/metrics'
import * as api from '../../api' import * as api from '../../api'
import * as url from '../../util/url' import * as url from '../../util/url'
import { VISITORS_METRIC, PERCENTAGE_METRIC, maybeWithCR } from '../reports/metrics';
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
// Icons copied from https://github.com/alrra/browser-logos // Icons copied from https://github.com/alrra/browser-logos
@ -55,13 +55,21 @@ function Browsers({ query, site, afterFetchData }) {
return browserIconFor(listItem.name) return browserIconFor(listItem.name)
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Browser" keyLabel="Browser"
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} metrics={chooseMetrics()}
query={query} query={query}
renderIcon={renderIcon} renderIcon={renderIcon}
/> />
@ -92,13 +100,21 @@ function BrowserVersions({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Browser version" keyLabel="Browser version"
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} metrics={chooseMetrics()}
renderIcon={renderIcon} renderIcon={renderIcon}
query={query} query={query}
/> />
@ -148,6 +164,14 @@ function OperatingSystems({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage({meta: {hiddenonMobile: true}})
].filter(metric => !!metric)
}
function renderIcon(listItem) { function renderIcon(listItem) {
return osIconFor(listItem.name) return osIconFor(listItem.name)
} }
@ -159,7 +183,7 @@ function OperatingSystems({ query, site, afterFetchData }) {
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
renderIcon={renderIcon} renderIcon={renderIcon}
keyLabel="Operating system" keyLabel="Operating system"
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} metrics={chooseMetrics()}
query={query} query={query}
/> />
) )
@ -189,6 +213,14 @@ function OperatingSystemVersions({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
@ -196,7 +228,7 @@ function OperatingSystemVersions({ query, site, afterFetchData }) {
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Operating System Version" keyLabel="Operating System Version"
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} metrics={chooseMetrics()}
query={query} query={query}
/> />
) )
@ -221,13 +253,21 @@ function ScreenSizes({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Screen size" keyLabel="Screen size"
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} metrics={chooseMetrics()}
query={query} query={query}
renderIcon={renderIcon} renderIcon={renderIcon}
/> />

View File

@ -1,41 +1,27 @@
import numberFormatter, {durationFormatter} from '../../util/number-formatter' import numberFormatter, {durationFormatter} from '../../util/number-formatter'
import { getFiltersByKeyPrefix, getGoalFilter } from '../../util/filters' import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters'
import { revenueAvailable } from '../../query'
export function getGraphableMetrics(query, site) { export function getGraphableMetrics(query, site) {
const isRealtime = query.period === 'realtime' const isRealtime = query.period === 'realtime'
const goalFilter = getGoalFilter(query) const isGoalFilter = hasGoalFilter(query)
const hasPageFilter = getFiltersByKeyPrefix(query, "page").length > 0 const isPageFilter = getFiltersByKeyPrefix(query, "page").length > 0
if (isRealtime && goalFilter) { if (isRealtime && isGoalFilter) {
return ["visitors"] return ["visitors"]
} else if (isRealtime) { } else if (isRealtime) {
return ["visitors", "pageviews"] return ["visitors", "pageviews"]
} else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) { } else if (isGoalFilter && revenueAvailable(query, site)) {
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"] return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
} else if (goalFilter) { } else if (isGoalFilter) {
return ["visitors", "events", "conversion_rate"] return ["visitors", "events", "conversion_rate"]
} else if (hasPageFilter) { } else if (isPageFilter) {
return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"] return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"]
} else { } else {
return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"] return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"]
} }
} }
// Revenue metrics can only be graphed if:
// * The query is filtered by at least one revenue goal
// * All revenue goals in filter have the same currency
function canGraphRevenueMetrics([_operation, _filterKey, clauses], site) {
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
return clauses.includes(rg.event_name)
})
const singleCurrency = revenueGoalsInFilter.every((rg) => {
return rg.currency === revenueGoalsInFilter[0].currency
})
return revenueGoalsInFilter.length > 0 && singleCurrency
}
export const METRIC_LABELS = { export const METRIC_LABELS = {
'visitors': 'Visitors', 'visitors': 'Visitors',
'pageviews': 'Pageviews', 'pageviews': 'Pageviews',

View File

@ -136,7 +136,14 @@ export default function VisitorGraph(props) {
{(topStatsLoading || graphLoading) && renderLoader()} {(topStatsLoading || graphLoading) && renderLoader()}
<FadeIn show={!(topStatsLoading || graphLoading)}> <FadeIn show={!(topStatsLoading || graphLoading)}>
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}> <div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}>
<TopStats site={site} query={query} data={topStatData} onMetricUpdate={onMetricUpdate} tooltipBoundary={topStatsBoundary.current} lastLoadTimestamp={lastLoadTimestamp} /> <TopStats
site={site}
query={query}
data={topStatData}
onMetricUpdate={onMetricUpdate}
tooltipBoundary={topStatsBoundary.current}
lastLoadTimestamp={lastLoadTimestamp}
/>
</div> </div>
<div className="relative px-2"> <div className="relative px-2">
{graphRefreshing && renderLoader()} {graphRefreshing && renderLoader()}

View File

@ -6,7 +6,8 @@ import CountriesMap from './map'
import * as api from '../../api' import * as api from '../../api'
import { apiPath, sitePath } from '../../util/url' import { apiPath, sitePath } from '../../util/url'
import ListReport from '../reports/list' import ListReport from '../reports/list'
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics'; import * as metrics from '../reports/metrics';
import { hasGoalFilter } from "../../util/filters"
import { getFiltersByKeyPrefix } from '../../util/filters'; import { getFiltersByKeyPrefix } from '../../util/filters';
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
@ -27,6 +28,13 @@ function Countries({ query, site, onClick, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
@ -34,7 +42,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
onClick={onClick} onClick={onClick}
keyLabel="Country" keyLabel="Country"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={sitePath('countries')} detailsLink={sitePath('countries')}
query={query} query={query}
renderIcon={renderIcon} renderIcon={renderIcon}
@ -60,6 +68,13 @@ function Regions({ query, site, onClick, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
@ -67,7 +82,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
onClick={onClick} onClick={onClick}
keyLabel="Region" keyLabel="Region"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={sitePath('regions')} detailsLink={sitePath('regions')}
query={query} query={query}
renderIcon={renderIcon} renderIcon={renderIcon}
@ -93,13 +108,20 @@ function Cities({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="City" keyLabel="City"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={sitePath('cities')} detailsLink={sitePath('cities')}
query={query} query={query}
renderIcon={renderIcon} renderIcon={renderIcon}

View File

@ -0,0 +1,306 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import * as api from '../../api'
import { useDebouncedEffect, useMountedEffect } from '../../custom-hooks'
import { trimURL } from '../../util/url'
import { FilterLink } from "../reports/list";
const LIMIT = 100
const MIN_HEIGHT_PX = 500
// The main function component for rendering the "Details" reports on the dashboard,
// i.e. a breakdown by a single (non-time) dimension, with a given set of metrics.
// BreakdownModal is expected to be rendered inside a `<Modal>`, which has it's own
// specific URL pathname (e.g. /plausible.io/sources). During the lifecycle of a
// BreakdownModal, the `query` object is not expected to change.
// ### Search As You Type
// Debounces API requests when a search input changes and applies a `contains` filter
// on the given breakdown dimension (see the required `addSearchFilter` prop)
// ### Filter Links
// Dimension values can act as links back to the dashboard, where that specific value
// will be filtered by. (see the `getFilterInfo` required prop)
// ### Pagination
// By default, the component fetches `LIMIT` results. When exactly this number of
// results is received, a "Load More" button is rendered for fetching the next page
// of results.
// ### Required Props
// * `site` - the current dashboard site
// * `query` - a read-only query object representing the query state of the
// dashboard (e.g. `filters`, `period`, `with_imported`, etc)
// * `reportInfo` - a map with the following required keys:
// * `title` - the title of the report to render on the top left
// * `endpoint` - the last part of the endpoint (e.g. "/sources") to query
// * `dimensionLabel` - a string to render as the dimension column header.
// * `metrics` - a list of `Metric` class objects which represent the columns
// rendered in the report
// * `getFilterInfo` - a function that takes a `listItem` and returns a map with
// the necessary information to be able to link to a dashboard where that item
// is filtered by. If a list item is not supposed to be a filter link, this
// function should return `null` for that item.
// ### Optional Props
// * `renderIcon` - a function that renders an icon for the given list item.
// * `getExternalLinkURL` - a function that takes a list litem, and returns a
// valid link href for this item. If the item is not supposed to be a link,
// the function should return `null` for that item. Otherwise, if the returned
// value exists, a small pop-out icon will be rendered whenever the list item
// is hovered. When the icon is clicked, opens the external link in a new tab.
// * `searchEnabled` - a boolean that determines if the search feature is enabled.
// When true, the `addSearchFilter` function is expected. Is true by default.
// * `addSearchFilter` - a function that takes a query object and a search string
// as arguments, and returns a new `query` with an additional search filter.
// * `afterFetchData` - a callback function taking an API response as an argument.
// If this function is passed via props, it will be called after a successful
// API response from the `fetchData` function.
// * `afterFetchNextPage` - a function with the same behaviour as `afterFetchData`,
// but will be called after a successful next page load in `fetchNextPage`.
export default function BreakdownModal({
site,
query,
reportInfo,
metrics,
renderIcon,
getExternalLinkURL,
searchEnabled = true,
afterFetchData,
afterFetchNextPage,
addSearchFilter,
getFilterInfo
}) {
const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}`
const [initialLoading, setInitialLoading] = useState(true)
const [loading, setLoading] = useState(true)
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [results, setResults] = useState([])
const [page, setPage] = useState(1)
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const searchBoxRef = useRef(null)
useMountedEffect(() => { fetchNextPage() }, [page])
useDebouncedEffect(() => {
setSearch(searchInput)
}, [searchInput], 300)
useEffect(() => { fetchData() }, [search])
useEffect(() => {
if (!searchEnabled) { return }
const searchBox = searchBoxRef.current
const handleKeyUp = (event) => {
if (event.key === 'Escape') {
event.target.blur()
event.stopPropagation()
}
}
searchBox.addEventListener('keyup', handleKeyUp);
return () => {
searchBox.removeEventListener('keyup', handleKeyUp);
}
}, [])
const fetchData = useCallback(() => {
setLoading(true)
api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true })
.then((response) => {
if (typeof afterFetchData === 'function') {
afterFetchData(response)
}
setInitialLoading(false)
setLoading(false)
setPage(1)
setResults(response.results)
setMoreResultsAvailable(response.results.length === LIMIT)
})
}, [search])
function fetchNextPage() {
if (page > 1) {
api.get(endpoint, withSearch(query), { limit: LIMIT, page, detailed: true })
.then((response) => {
if (typeof afterFetchNextPage === 'function') {
afterFetchNextPage(response)
}
setLoading(false)
setResults(results.concat(response.results))
setMoreResultsAvailable(response.results.length === LIMIT)
})
}
}
function withSearch(query) {
if (searchEnabled && search !== '') {
return addSearchFilter(query, search)
}
return query
}
function loadNextPage() {
setLoading(true)
setPage(page + 1)
}
function maybeRenderIcon(item) {
if (typeof renderIcon === 'function') {
return renderIcon(item)
}
}
function maybeRenderExternalLink(item) {
if (typeof getExternalLinkURL === 'function') {
const linkUrl = getExternalLinkURL(item)
if (!linkUrl) { return null}
return (
<a target="_blank" href={linkUrl} rel="noreferrer" className="hidden group-hover:block">
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}
}
function renderRow(item) {
return (
<tr className="text-sm dark:text-gray-200" key={item.name}>
<td className="p-2 truncate flex items-center group">
{ maybeRenderIcon(item) }
<FilterLink
pathname={`/${encodeURIComponent(site.domain)}`}
query={query}
filterInfo={getFilterInfo(item)}
>
{trimURL(item.name, 40)}
</FilterLink>
{ maybeRenderExternalLink(item) }
</td>
{metrics.map((metric) => {
return (
<td key={metric.key} className="p-2 w-32 font-medium" align="right">
{metric.renderValue(item[metric.key])}
</td>
)
})}
</tr>
)
}
function renderInitialLoadingSpinner() {
return (
<div className="w-full h-full flex flex-col justify-center" style={{minHeight: `${MIN_HEIGHT_PX}px`}}>
<div className="mx-auto loading"><div></div></div>
</div>
)
}
function renderSmallLoadingSpinner() {
return (
<div className="loading sm"><div></div></div>
)
}
function renderLoadMoreButton() {
return (
<div className="w-full text-center my-4">
<button onClick={loadNextPage} type="button" className="button">
Load more
</button>
</div>
)
}
function handleInputChange(e) {
setSearchInput(e.target.value)
}
function renderSearchInput() {
return (
<input
ref={searchBoxRef}
type="text"
placeholder="Search"
className="shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 w-48"
onChange={handleInputChange}
/>
)
}
function renderModalBody() {
if (results) {
return (
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th
className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="left"
>
{reportInfo.dimensionLabel}
</th>
{metrics.map((metric) => {
return (
<th key={metric.key} className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">
{metric.renderLabel(query)}
</th>
)
})}
</tr>
</thead>
<tbody>
{ results.map(renderRow) }
</tbody>
</table>
</main>
)
}
}
return (
<div className="w-full h-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold dark:text-gray-100">{ reportInfo.title }</h1>
{ !initialLoading && loading && renderSmallLoadingSpinner() }
</div>
{ searchEnabled && renderSearchInput()}
</div>
<div className="my-4 border-b border-gray-300"></div>
<div style={{minHeight: `${MIN_HEIGHT_PX}px`}}>
{ initialLoading && renderInitialLoadingSpinner() }
{ !initialLoading && renderModalBody() }
{ !loading && moreResultsAvailable && renderLoadMoreButton() }
</div>
</div>
)
}

View File

@ -1,128 +1,72 @@
import React, { useEffect, useState } from "react"; import React, { useCallback, useState } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import withQueryContext from "../../components/query-context-hoc";
import * as url from "../../util/url"; import BreakdownModal from "./breakdown-modal";
import numberFormatter from '../../util/number-formatter' import * as metrics from "../reports/metrics";
import { parseQuery } from '../../query'
import { replaceFilterByPrefix } from '../../util/filters'
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function ConversionsModal(props) { function ConversionsModal(props) {
const site = props.site const { site, query } = props
const query = parseQuery(props.location.search, site) const [showRevenue, setShowRevenue] = useState(false)
const [loading, setLoading] = useState(true) const reportInfo = {
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) title: 'Goal Conversions',
const [page, setPage] = useState(1) dimension: 'goal',
const [list, setList] = useState([]) endpoint: '/conversions',
dimensionLabel: "Goal"
}
useEffect(() => { const getFilterInfo = useCallback((listItem) => {
fetchData() return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, []) }, [])
function fetchData() { function chooseMetrics() {
api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page }) return [
.then((response) => { metrics.createVisitors({renderLabel: (_query) => "Uniques"}),
setLoading(false) metrics.createEvents({renderLabel: (_query) => "Total"}),
setList(list.concat(response.results)) metrics.createConversionRate(),
setPage(page + 1) showRevenue && metrics.createAverageRevenue(),
setMoreResultsAvailable(response.results.length >= 100) showRevenue && metrics.createTotalRevenue(),
}) ].filter(metric => !!metric)
} }
function loadMore() { // After a successful API response, we want to scan the rows of the
setLoading(true) // response and update the internal `showRevenue` state, which decides
fetchData() // whether revenue metrics are passed into BreakdownModal in `metrics`.
} const afterFetchData = useCallback((res) => {
setShowRevenue(revenueInResponse(res))
}, [showRevenue])
function renderLoadMore() { // After fetching the next page, we never want to set `showRevenue` to
return ( // `false` as revenue metrics might exist in previously loaded data.
<div className="w-full text-center my-4"> const afterFetchNextPage = useCallback((res) => {
<button onClick={loadMore} type="button" className="button"> if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
Load more }, [showRevenue])
</button>
</div>
)
}
function filterSearchLink(listItem) { function revenueInResponse(apiResponse) {
const filters = replaceFilterByPrefix(query, "goal", ["is", "goal", [listItem.name]]) return apiResponse.results.some((item) => item.total_revenue)
return url.updatedQuery({ filters })
}
function renderListItem(listItem, hasRevenue) {
return (
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2">
<Link
to={{ pathname: '/', search: filterSearchLink(listItem) }}
className="hover:underline block truncate">
{listItem.name}
</Link>
</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
</tr>
)
}
function renderLoading() {
return <div className="loading my-16 mx-auto"><div></div></div>
}
function renderBody() {
const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue)
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">Goal Conversions</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">Goal</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Uniques</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
</tr>
</thead>
<tbody>
{list.map((item) => renderListItem(item, hasRevenue))}
</tbody>
</table>
</main>
</>
)
} }
return ( return (
<Modal> <Modal site={site}>
{renderBody()} <BreakdownModal
{loading && renderLoading()} site={site}
{!loading && moreResultsAvailable && renderLoadMore()} query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
afterFetchData={BUILD_EXTRA ? afterFetchData : undefined}
afterFetchNextPage={BUILD_EXTRA ? afterFetchNextPage : undefined}
getFilterInfo={getFilterInfo}
searchEnabled={false}
/>
</Modal> </Modal>
) )
} }
export default withRouter(ConversionsModal) export default withRouter(withQueryContext(ConversionsModal))

View File

@ -1,158 +1,67 @@
import React from "react"; import React, {useCallback} from "react";
import { Link, withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import { hasGoalFilter } from "../../util/filters";
import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { addFilter } from '../../query'
import { parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal";
import { trimURL, updatedQuery } from '../../util/url' import * as metrics from '../reports/metrics'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; import withQueryContext from "../../components/query-context-hoc";
class EntryPagesModal extends React.Component { function EntryPagesModal(props) {
constructor(props) { const { site, query } = props
super(props)
this.state = { const reportInfo = {
loading: true, title: 'Entry Pages',
query: parseQuery(props.location.search, props.site), dimension: 'entry_page',
pages: [], endpoint: '/entry-pages',
page: 1, dimensionLabel: 'Entry page'
moreResultsAvailable: false }
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
} }
} }, [])
componentDidMount() { const addSearchFilter = useCallback((query, searchString) => {
this.loadPages(); return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
} }, [])
loadPages() { function chooseMetrics() {
const { query, page } = this.state; if (hasGoalFilter(query)) {
return [
api.get( metrics.createTotalVisitors(),
`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`, metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
query, metrics.createConversionRate()
{ limit: 100, page } ]
)
.then(
(response) => this.setState((state) => ({
loading: false,
pages: state.pages.concat(response.results),
moreResultsAvailable: response.results.length === 100
}))
)
}
loadMore() {
const { page } = this.state;
this.setState({ loading: true, page: page + 1 }, this.loadPages.bind(this))
}
formatBounceRate(page) {
if (typeof (page.bounce_rate) === 'number') {
return `${page.bounce_rate}%`;
}
return '-';
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
showExtra() {
return this.state.query.period !== 'realtime' && !this.showConversionRate()
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
} }
if (this.showConversionRate()) { if (query.period === 'realtime') {
return 'Conversions' return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
} }
return 'Visitors' return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createVisits({renderLabel: (_query) => "Total Entrances" }),
metrics.createVisitDuration()
]
} }
renderPage(page) { return (
const filters = replaceFilterByPrefix(this.state.query, "entry_page", ["is", "entry_page", [page.name]]) <Modal site={site}>
return ( <BreakdownModal
<tr className="text-sm dark:text-gray-200" key={page.name}> site={site}
<td className="p-2 truncate"> query={query}
<Link reportInfo={reportInfo}
to={{ metrics={chooseMetrics()}
pathname: `/`, getFilterInfo={getFilterInfo}
search: updatedQuery({ filters }) addSearchFilter={addSearchFilter}
}} />
className="hover:underline" </Modal>
> )
{trimURL(page.name, 40)}
</Link>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{durationFormatter(page.visit_duration)}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
</tr>
)
}
renderLoading() {
if (this.state.loading) {
return <div className="loading my-16 mx-auto"><div></div></div>
} else if (this.state.moreResultsAvailable) {
return (
<div className="w-full text-center my-4">
<button onClick={this.loadMore.bind(this)} type="button" className="button">
Load more
</button>
</div>
)
}
}
renderBody() {
if (this.state.pages) {
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">Entry Pages</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th
className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="left"
>Page url
</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Visitors </th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >{this.label()} </th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Entrances </th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Visit Duration </th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >CR </th>}
</tr>
</thead>
<tbody>
{this.state.pages.map(this.renderPage.bind(this))}
</tbody>
</table>
</main>
</>
)
}
}
render() {
return (
<Modal>
{this.renderBody()}
{this.renderLoading()}
</Modal>
)
}
} }
export default withRouter(EntryPagesModal) export default withRouter(withQueryContext(EntryPagesModal))

View File

@ -1,135 +1,67 @@
import React from "react"; import React, {useCallback} from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import { hasGoalFilter } from "../../util/filters";
import numberFormatter, { percentageFormatter } from '../../util/number-formatter' import { addFilter } from '../../query'
import { parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal";
import { trimURL, updatedQuery } from '../../util/url' import * as metrics from '../reports/metrics'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; import withQueryContext from "../../components/query-context-hoc";
class ExitPagesModal extends React.Component {
constructor(props) { function ExitPagesModal(props) {
super(props) const { site, query } = props
this.state = {
loading: true, const reportInfo = {
query: parseQuery(props.location.search, props.site), title: 'Exit Pages',
pages: [], dimension: 'exit_page',
page: 1, endpoint: '/exit-pages',
moreResultsAvailable: false dimensionLabel: 'Page url'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
} }
} }, [])
componentDidMount() { const addSearchFilter = useCallback((query, searchString) => {
this.loadPages(); return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
} }, [])
loadPages() { function chooseMetrics() {
const { query, page } = this.state; if (hasGoalFilter(query)) {
return [
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page }) metrics.createTotalVisitors(),
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 }))) metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
} metrics.createConversionRate()
]
loadMore() {
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
showExtra() {
return this.state.query.period !== 'realtime' && !this.showConversionRate()
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
} }
if (this.showConversionRate()) { if (query.period === 'realtime') {
return 'Conversions' return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
} }
return 'Visitors' return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createVisits({renderLabel: (_query) => "Total Exits" }),
metrics.createExitRate()
]
} }
renderPage(page) { return (
const filters = replaceFilterByPrefix(this.state.query, "exit_page", ["is", "exit_page", [page.name]]) <Modal site={site}>
return ( <BreakdownModal
<tr className="text-sm dark:text-gray-200" key={page.name}> site={site}
<td className="p-2 truncate"> query={query}
<Link reportInfo={reportInfo}
to={{ metrics={chooseMetrics()}
pathname: `/`, getFilterInfo={getFilterInfo}
search: updatedQuery({ filters }) addSearchFilter={addSearchFilter}
}} />
className="hover:underline" </Modal>
> )
{trimURL(page.name, 40)}
</Link>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{percentageFormatter(page.exit_rate)}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
</tr>
)
}
renderLoading() {
if (this.state.loading) {
return <div className="loading my-16 mx-auto"><div></div></div>
} else if (this.state.moreResultsAvailable) {
return (
<div className="w-full text-center my-4">
<button onClick={this.loadMore.bind(this)} type="button" className="button">
Load more
</button>
</div>
)
}
}
renderBody() {
if (this.state.pages) {
return (
<React.Fragment>
<h1 className="text-xl font-bold dark:text-gray-100">Exit Pages</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Page url</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Visitors </th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total Exits</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Exit Rate</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>
{this.state.pages.map(this.renderPage.bind(this))}
</tbody>
</table>
</main>
</React.Fragment>
)
}
}
render() {
return (
<Modal>
{this.renderBody()}
{this.renderLoading()}
</Modal>
)
}
} }
export default withRouter(ExitPagesModal) export default withRouter(withQueryContext(ExitPagesModal))

View File

@ -0,0 +1,73 @@
import React, { useCallback } from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import withQueryContext from "../../components/query-context-hoc";
import { hasGoalFilter } from "../../util/filters";
import BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
const VIEWS = {
countries: {title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country'},
regions: {title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region'},
cities: {title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City'},
}
function LocationsModal(props) {
const { site, query, location } = props
const urlParts = location.pathname.split('/')
const currentView = urlParts[urlParts.length - 1]
const reportInfo = VIEWS[currentView]
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.code]]
}
}, [])
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
}
return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
currentView === 'countries' && metrics.createPercentage()
].filter(metric => !!metric)
}
const renderIcon = useCallback((listItem) => {
return (
<span className="mr-1">{listItem.country_flag || listItem.flag}</span>
)
}, [])
return (
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
renderIcon={renderIcon}
searchEnabled={false}
/>
</Modal>
)
}
export default withRouter(withQueryContext(LocationsModal))

View File

@ -1,152 +1,68 @@
import React from "react"; import React, {useCallback} from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import { hasGoalFilter } from "../../util/filters";
import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { addFilter } from '../../query'
import { parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal";
import { trimURL, updatedQuery } from '../../util/url' import * as metrics from '../reports/metrics'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; import withQueryContext from "../../components/query-context-hoc";
class PagesModal extends React.Component { function PagesModal(props) {
constructor(props) { const { site, query } = props
super(props)
this.state = { const reportInfo = {
loading: true, title: 'Top Pages',
query: parseQuery(props.location.search, props.site), dimension: 'page',
pages: [], endpoint: '/pages',
page: 1, dimensionLabel: 'Page url'
moreResultsAvailable: false }
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
} }
} }, [])
componentDidMount() { const addSearchFilter = useCallback((query, searchString) => {
this.loadPages(); return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
} }, [])
loadPages() { function chooseMetrics() {
const detailed = this.showExtra() if (hasGoalFilter(query)) {
const { query, page } = this.state; return [
metrics.createTotalVisitors(),
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed }) metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 }))) metrics.createConversionRate()
} ]
loadMore() {
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
}
showExtra() {
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
}
showPageviews() {
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
formatBounceRate(page) {
if (typeof (page.bounce_rate) === 'number') {
return page.bounce_rate + '%'
} else {
return '-'
}
}
renderPage(page) {
const filters = replaceFilterByPrefix(this.state.query, "page", ["is", "page", [page.name]])
const timeOnPage = page['time_on_page'] ? durationFormatter(page['time_on_page']) : '-';
return (
<tr className="text-sm dark:text-gray-200" key={page.name}>
<td className="p-2">
<Link
to={{
pathname: `/`,
search: updatedQuery({ filters })
}}
className="hover:underline block truncate"
>
{trimURL(page.name, 50)}
</Link>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{page.total_visitors}</td>}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
{this.showPageviews() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.pageviews)}</td>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{timeOnPage}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{page.conversion_rate}%</td>}
</tr>
)
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
} }
if (this.showConversionRate()) { if (query.period === 'realtime') {
return 'Conversions' return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
} }
return 'Visitors' return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createPageviews(),
metrics.createBounceRate(),
metrics.createTimeOnPage()
]
} }
renderLoading() { return (
if (this.state.loading) { <Modal site={site}>
return <div className="loading my-16 mx-auto"><div></div></div> <BreakdownModal
} else if (this.state.moreResultsAvailable) { site={site}
return ( query={query}
<div className="w-full text-center my-4"> reportInfo={reportInfo}
<button onClick={this.loadMore.bind(this)} type="button" className="button"> metrics={chooseMetrics()}
Load more getFilterInfo={getFilterInfo}
</button> addSearchFilter={addSearchFilter}
</div> />
) </Modal>
} )
}
renderBody() {
if (this.state.pages) {
return (
<React.Fragment>
<h1 className="text-xl font-bold dark:text-gray-100">Top Pages</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Page url</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showPageviews() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Pageviews</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Time on Page</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>
{this.state.pages.map(this.renderPage.bind(this))}
</tbody>
</table>
</main>
</React.Fragment>
)
}
}
render() {
return (
<Modal>
{this.renderBody()}
{this.renderLoading()}
</Modal>
)
}
} }
export default withRouter(PagesModal) export default withRouter(withQueryContext(PagesModal))

View File

@ -1,137 +1,63 @@
import React, { useEffect, useState } from "react"; import React, { useCallback } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import withQueryContext from "../../components/query-context-hoc";
import * as url from "../../util/url"; import { addFilter } from '../../query'
import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { EVENT_PROPS_PREFIX, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters" import { EVENT_PROPS_PREFIX, hasGoalFilter } from "../../util/filters"
import BreakdownModal from "./breakdown-modal";
/*global BUILD_EXTRA*/ import * as metrics from "../reports/metrics";
/*global require*/ import { revenueAvailable } from "../../query";
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function PropsModal(props) { function PropsModal(props) {
const site = props.site const {site, query, location} = props
const query = parseQuery(props.location.search, site) const propKey = location.pathname.split('/').filter(i => i).pop()
const propKey = props.location.pathname.split('/').filter(i => i).pop() /*global BUILD_EXTRA*/
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
const [loading, setLoading] = useState(true) const reportInfo = {
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
const [page, setPage] = useState(1) dimension: propKey,
const [list, setList] = useState([]) endpoint: `/custom-prop-values/${propKey}`,
dimensionLabel: propKey
}
useEffect(() => { const getFilterInfo = useCallback((listItem) => {
fetchData() return {
prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
}
}, []) }, [])
function fetchData() { const addSearchFilter = useCallback((query, searchString) => {
api.get(url.apiPath(site, `/custom-prop-values/${propKey}`), query, { limit: 100, page }) return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]])
.then((response) => { }, [])
setLoading(false)
setList(list.concat(response.results))
setPage(page + 1)
setMoreResultsAvailable(response.results.length >= 100)
})
}
function loadMore() { function chooseMetrics() {
setLoading(true) return [
fetchData() metrics.createVisitors({renderLabel: (_query) => "Visitors"}),
} metrics.createEvents({renderLabel: (_query) => "Events"}),
hasGoalFilter(query) && metrics.createConversionRate(),
function renderLoadMore() { !hasGoalFilter(query) && metrics.createPercentage(),
return ( showRevenueMetrics && metrics.createAverageRevenue(),
<div className="w-full text-center my-4"> showRevenueMetrics && metrics.createTotalRevenue(),
<button onClick={loadMore} type="button" className="button"> ].filter(metric => !!metric)
Load more
</button>
</div>
)
}
function filterSearchLink(listItem) {
const filters = replaceFilterByPrefix(query, EVENT_PROPS_PREFIX, ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]])
return url.updatedQuery({ filters })
}
function renderListItem(listItem, hasRevenue) {
return (
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2">
<Link
to={{ pathname: '/', search: filterSearchLink(listItem) }}
className="hover:underline block truncate">
{url.trimURL(listItem.name, 30)}
</Link>
</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
{
hasGoalFilter(query) ? (
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
) : (
<td className="p-2 w-24 font-medium" align="right">{listItem.percentage}</td>
)
}
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
</tr>
)
}
function renderLoading() {
return <div className="loading my-16 mx-auto"><div></div></div>
}
function renderBody() {
const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue)
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">{specialTitleWhenGoalFilter(query, 'Custom Property Breakdown')}</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">{propKey}</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Events</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{hasGoalFilter(query) ? 'CR' : '%'}</th>
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
</tr>
</thead>
<tbody>
{list.map((item) => renderListItem(item, hasRevenue))}
</tbody>
</table>
</main>
</>
)
} }
return ( return (
<Modal> <Modal site={site}>
{renderBody()} <BreakdownModal
{loading && renderLoading()} site={site}
{!loading && moreResultsAvailable && renderLoadMore()} query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
/>
</Modal> </Modal>
) )
} }
export default withRouter(PropsModal) export default withRouter(withQueryContext(PropsModal))

View File

@ -1,147 +1,85 @@
import React from "react"; import React, { useCallback } from "react";
import { Link, withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import withQueryContext from "../../components/query-context-hoc";
import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { hasGoalFilter } from "../../util/filters";
import { parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal";
import { updatedQuery } from "../../util/url"; import * as metrics from "../reports/metrics";
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; import { addFilter } from "../../query";
class ReferrerDrilldownModal extends React.Component { function ReferrerDrilldownModal(props) {
constructor(props) { const { site, query, match } = props
super(props)
this.state = { const reportInfo = {
loading: true, title: "Referrer Drilldown",
query: parseQuery(props.location.search, props.site) dimension: 'referrer',
endpoint: `/referrers/${match.params.referrer}`,
dimensionLabel: "Referrer"
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ['is', reportInfo.dimension, [listItem.name]]
} }
} }, [])
componentDidMount() { const addSearchFilter = useCallback((query, searchString) => {
const detailed = this.showExtra() return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [])
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.match.params.referrer}`, this.state.query, { limit: 100, detailed }) function chooseMetrics() {
.then((response) => this.setState({ loading: false, referrers: response.results })) if (hasGoalFilter(query)) {
} return [
metrics.createTotalVisitors(),
showExtra() { metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query) metrics.createConversionRate()
} ]
showConversionRate() {
return hasGoalFilter(this.state.query)
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
} }
if (this.showConversionRate()) { if (query.period === 'realtime') {
return 'Conversions' return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
} }
return 'Visitors' return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createBounceRate(),
metrics.createVisitDuration()
]
} }
formatBounceRate(ref) { const renderIcon = useCallback((listItem) => {
if (typeof (ref.bounce_rate) === 'number') {
return ref.bounce_rate + '%'
} else {
return '-'
}
}
formatDuration(referrer) {
if (typeof (referrer.visit_duration) === 'number') {
return durationFormatter(referrer.visit_duration)
} else {
return '-'
}
}
renderExternalLink(name) {
if (name !== 'Direct / None') {
return (
<a target="_blank" href={'//' + name} rel="noreferrer" className="hidden group-hover:block">
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}
}
renderReferrerName(referrer) {
const filters = replaceFilterByPrefix(this.state.query, "referrer", ["is", "referrer", [referrer.name]])
return ( return (
<span className="flex group items-center"> <img
<img src={`/favicon/sources/${referrer.name}`} referrerPolicy="no-referrer" className="h-4 w-4 mr-2 inline" /> src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
<Link className="h-4 w-4 mr-2 align-middle inline"
className="block truncate hover:underline dark:text-gray-200" />
to={{ search: updatedQuery({ filters }), pathname: '/' + this.props.site.domain }}
title={referrer.name}
>
{referrer.name}
</Link>
{this.renderExternalLink(referrer.name)}
</span>
) )
} }, [])
renderReferrer(referrer) { const getExternalLinkURL = useCallback((listItem) => {
return ( if (listItem.name !== "Direct / None") {
<tr className="text-sm dark:text-gray-200" key={referrer.name}> return '//' + listItem.name
<td className="p-2">
{this.renderReferrerName(referrer)}
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.total_visitors)}</td>}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.visitors)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(referrer)}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{referrer.conversion_rate}%</td>}
</tr>
)
}
renderBody() {
if (this.state.loading) {
return (
<div className="loading mt-32 mx-auto"><div></div></div>
)
} else if (this.state.referrers) {
return (
<React.Fragment>
<h1 className="text-xl font-bold dark:text-gray-100">Referrer drilldown</h1>
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<main className="modal__content mt-0">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed mt-4">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Referrer</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visit duration</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>
{this.state.referrers.map(this.renderReferrer.bind(this))}
</tbody>
</table>
</main>
</React.Fragment>
)
} }
} }, [])
render() { return (
return ( <Modal site={site}>
<Modal> <BreakdownModal
{this.renderBody()} site={site}
</Modal> query={query}
) reportInfo={reportInfo}
} metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={renderIcon}
getExternalLinkURL={getExternalLinkURL}
/>
</Modal>
)
} }
export default withRouter(ReferrerDrilldownModal) export default withRouter(withQueryContext(ReferrerDrilldownModal))

View File

@ -1,184 +1,96 @@
import React from "react"; import React, { useCallback } from "react";
import { Link, withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import withQueryContext from "../../components/query-context-hoc";
import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { hasGoalFilter } from "../../util/filters";
import { parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal";
import { updatedQuery } from "../../util/url"; import * as metrics from "../reports/metrics";
import { FILTER_OPERATIONS, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; import { addFilter } from "../../query";
const TITLES = { const VIEWS = {
sources: 'Top Sources', sources: {
utm_mediums: 'Top UTM mediums', info: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'},
utm_sources: 'Top UTM sources', renderIcon: (listItem) => {
utm_campaigns: 'Top UTM campaigns',
utm_contents: 'Top UTM contents',
utm_terms: 'Top UTM Terms'
}
class SourcesModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
sources: [],
query: parseQuery(props.location.search, props.site),
page: 1,
moreResultsAvailable: false
}
}
loadSources() {
const { site } = this.props
const { query, page, sources } = this.state
const detailed = this.showExtra()
api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentView()}`, query, { limit: 100, page, detailed })
.then((response) => this.setState({ loading: false, sources: sources.concat(response.results), moreResultsAvailable: response.results.length === 100 }))
}
componentDidMount() {
this.loadSources()
}
componentDidUpdate(prevProps) {
if (this.props.location.pathname !== prevProps.location.pathname) {
this.setState({ sources: [], loading: true }, this.loadSources.bind(this))
}
}
currentView() {
const urlparts = this.props.location.pathname.split('/')
return urlparts[urlparts.length - 1]
}
filterKey() {
const view = this.currentView()
if (view === 'sources') return 'source'
if (view === 'utm_mediums') return 'utm_medium'
if (view === 'utm_sources') return 'utm_source'
if (view === 'utm_campaigns') return 'utm_campaign'
if (view === 'utm_contents') return 'utm_content'
if (view === 'utm_terms') return 'utm_term'
}
showExtra() {
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
loadMore() {
this.setState({ loading: true, page: this.state.page + 1 }, this.loadSources.bind(this))
}
formatBounceRate(page) {
if (typeof (page.bounce_rate) === 'number') {
return page.bounce_rate + '%'
} else {
return '-'
}
}
formatDuration(source) {
if (typeof (source.visit_duration) === 'number') {
return durationFormatter(source.visit_duration)
} else {
return '-'
}
}
icon(source) {
if (this.currentView() === 'sources') {
return ( return (
<img <img
src={`/favicon/sources/${encodeURIComponent(source.name)}`} src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
className="h-4 w-4 mr-2 align-middle inline" className="h-4 w-4 mr-2 align-middle inline"
/> />
) )
} }
} },
utm_mediums: {
renderSource(source) { info: {title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium'}
const filters = replaceFilterByPrefix(this.state.query, this.filterKey(), [FILTER_OPERATIONS.is, this.filterKey(), [source.name]]) },
utm_sources: {
return ( info: {title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source'}
<tr className="text-sm dark:text-gray-200" key={source.name}> },
<td className="p-2"> utm_campaigns: {
{this.icon(source)} info: {title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign'}
<Link className="hover:underline" to={{ search: updatedQuery({ filters }), pathname: '/' + encodeURIComponent(this.props.site.domain) }}>{source.name}</Link> },
</td> utm_contents: {
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.total_visitors)}</td>} info: {title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content'}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.visitors)}</td> },
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(source)}</td>} utm_terms: {
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(source)}</td>} info: {title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term'}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{source.conversion_rate}%</td>} },
</tr>
)
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
}
if (this.showConversionRate()) {
return 'Conversions'
}
return 'Visitors'
}
renderLoading() {
if (this.state.loading) {
return <div className="loading my-16 mx-auto"><div></div></div>
} else if (this.state.moreResultsAvailable) {
return (
<div className="w-full text-center my-4">
<button onClick={this.loadMore.bind(this)} type="button" className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150">
Load more
</button>
</div>
)
}
}
title() {
return TITLES[this.currentView()]
}
render() {
return (
<Modal>
<h1 className="text-xl font-bold dark:text-gray-100">{this.title()}</h1>
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Source</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visit duration</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>
{this.state.sources.map(this.renderSource.bind(this))}
</tbody>
</table>
</main>
{this.renderLoading()}
</Modal>
)
}
} }
export default withRouter(SourcesModal) function SourcesModal(props) {
const { site, query, location } = props
const urlParts = location.pathname.split('/')
const currentView = urlParts[urlParts.length - 1]
const reportInfo = VIEWS[currentView].info
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [])
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [])
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
}
return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createBounceRate(),
metrics.createVisitDuration()
]
}
return (
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
renderIcon={VIEWS[currentView].renderIcon}
/>
</Modal>
)
}
export default withRouter(withQueryContext(SourcesModal))

View File

@ -1,120 +0,0 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { cleanLabels, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
import { updatedQuery } from "../../util/url";
class ModalTable extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site)
}
}
componentDidMount() {
api.get(this.props.endpoint, this.state.query, { limit: 100 })
.then((response) => this.setState({ loading: false, list: response.results }))
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
showPercentage() {
return this.props.showPercentage && !this.showConversionRate()
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
}
if (this.showConversionRate()) {
return 'Conversions'
}
return 'Visitors'
}
renderTableItem(tableItem) {
const filters = replaceFilterByPrefix(this.state.query, this.props.filterKey, [
"is", this.props.filterKey, [tableItem.code]
])
const labels = cleanLabels(filters, this.state.query.labels, this.props.filterKey, { [tableItem.code]: tableItem.name })
return (
<tr className="text-sm dark:text-gray-200" key={tableItem.name}>
<td className="p-2">
<Link
className="hover:underline"
to={{
search: updatedQuery({ filters, labels }),
pathname: `/`
}}
>
{this.props.renderIcon && this.props.renderIcon(tableItem)}
{this.props.renderIcon && ' '}
{tableItem.name}
</Link>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(tableItem.total_visitors)}</td>}
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(tableItem.visitors)}</td>
{this.showPercentage() && <td className="p-2 w-32 font-medium" align="right">{tableItem.percentage}</td>}
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(tableItem.conversion_rate)}%</td>}
</tr>
)
}
renderBody() {
if (this.state.loading) {
return (
<div className="loading mt-32 mx-auto"><div></div></div>
)
}
if (this.state.list) {
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">{this.props.title}</h1>
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">{this.props.keyLabel}</th>
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Visitors</th>}
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
{this.showPercentage() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">%</th>}
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
</tr>
</thead>
<tbody>
{this.state.list.map(this.renderTableItem.bind(this))}
</tbody>
</table>
</main>
</>
)
}
return null
}
render() {
return (
<Modal show={!this.state.loading}>
{this.renderBody()}
</Modal>
)
}
}
export default withRouter(ModalTable)

View File

@ -4,8 +4,9 @@ import * as storage from '../../util/storage'
import * as url from '../../util/url' import * as url from '../../util/url'
import * as api from '../../api' import * as api from '../../api'
import ListReport from './../reports/list' import ListReport from './../reports/list'
import { VISITORS_METRIC, maybeWithCR } from './../reports/metrics'; import * as metrics from './../reports/metrics'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
import { hasGoalFilter } from '../../util/filters';
function EntryPages({ query, site, afterFetchData }) { function EntryPages({ query, site, afterFetchData }) {
function fetchData() { function fetchData() {
@ -23,13 +24,20 @@ function EntryPages({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({defaultLabel: 'Unique Entrances', meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Entry page" keyLabel="Entry page"
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: 'Unique Entrances' }], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath('entry-pages')} detailsLink={url.sitePath('entry-pages')}
query={query} query={query}
externalLinkDest={externalLinkDest} externalLinkDest={externalLinkDest}
@ -54,13 +62,20 @@ function ExitPages({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({defaultLabel: 'Unique Exits', meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Exit page" keyLabel="Exit page"
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: "Unique Exits" }], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath('exit-pages')} detailsLink={url.sitePath('exit-pages')}
query={query} query={query}
externalLinkDest={externalLinkDest} externalLinkDest={externalLinkDest}
@ -85,13 +100,20 @@ function TopPages({ query, site, afterFetchData }) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({ meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={afterFetchData} afterFetchData={afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Page" keyLabel="Page"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath('pages')} detailsLink={url.sitePath('pages')}
query={query} query={query}
externalLinkDest={externalLinkDest} externalLinkDest={externalLinkDest}

View File

@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move'; import FlipMove from 'react-flip-move';
import { displayMetricValue, metricLabelFor } from './metrics';
import FadeIn from '../../fade-in' import FadeIn from '../../fade-in'
import MoreLink from '../more-link' import MoreLink from '../more-link'
import Bar from '../bar' import Bar from '../bar'
@ -17,18 +16,20 @@ const ROW_GAP_HEIGHT = 4
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
const COL_MIN_WIDTH = 70 const COL_MIN_WIDTH = 70
function FilterLink({ filterQuery, onClick, children }) { export function FilterLink({ pathname, query, filterInfo, onClick, children, extraClass }) {
const className = classNames('max-w-max w-full flex items-center md:overflow-hidden', { const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo })
'hover:underline': !!filterQuery
}) if (filterInfo) {
const {prefix, filter, labels} = filterInfo
const newFilters = replaceFilterByPrefix(query, prefix, filter)
const newLabels = cleanLabels(newFilters, query.labels, filter[1], labels)
const filterQuery = updatedQuery({ filters: newFilters, labels: newLabels })
let linkTo = { search: filterQuery.toString() }
if (pathname) { linkTo.pathname = pathname }
if (filterQuery) {
return ( return (
<Link <Link to={linkTo} onClick={onClick} className={className}>
to={{ search: filterQuery.toString() }}
onClick={onClick}
className={className}
>
{children} {children}
</Link> </Link>
) )
@ -62,12 +63,12 @@ function ExternalLink({ item, externalLinkDest }) {
// to be rendered, and should return a list of objects under a `results` key. Think of // to be rendered, and should return a list of objects under a `results` key. Think of
// these objects as rows. The number of columns that are **actually rendered** is also // these objects as rows. The number of columns that are **actually rendered** is also
// configurable through the `metrics` prop, which also defines the keys under which // configurable through the `metrics` prop, which also defines the keys under which
// column values are read. For example: // column values are read, and how they're rendered. For example:
// | keyLabel | METRIC_1.label | METRIC_2.label | ... // | keyLabel | METRIC_1.renderLabel(query) | METRIC_1.renderLabel(query) | ...
// |--------------------|---------------------------|---------------------------|----- // |--------------------|-----------------------------|-----------------------------| ---
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ... // | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ...
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ... // | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ...
// Further configuration of the report is possible through optional props. // Further configuration of the report is possible through optional props.
@ -80,9 +81,10 @@ function ExternalLink({ item, externalLinkDest }) {
// * `fetchData` - a function that returns an `api.get` promise that will resolve to an // * `fetchData` - a function that returns an `api.get` promise that will resolve to an
// object containing a `results` key. // object containing a `results` key.
// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at // * `metrics` - a list `Metric` class objects, containing at least the `key,`
// least the `name` and the `label` keys. If the metric should have a different label // `renderLabel`, and `renderValue` fields. Optionally, a Metric object can contain
// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`. // the keys `meta.plot` and `meta.hiddenOnMobile` to represent additional behaviour
// for this metric in the ListReport.
// * `getFilterFor` - a function that takes a list item and returns [prefix, filter, labels] // * `getFilterFor` - a function that takes a list item and returns [prefix, filter, labels]
// that should be applied when the list item is clicked. All existing filters matching prefix // that should be applied when the list item is clicked. All existing filters matching prefix
@ -164,12 +166,12 @@ export default function ListReport(props) {
// we want to display are actually there in the API response. // we want to display are actually there in the API response.
function getAvailableMetrics() { function getAvailableMetrics() {
return metrics.filter((metric) => { return metrics.filter((metric) => {
return state.list.some((listItem) => listItem[metric.name] != null) return state.list.some((listItem) => listItem[metric.key] != null)
}) })
} }
function hiddenOnMobileClass(metric) { function hiddenOnMobileClass(metric) {
if (metric.hiddenOnMobile) { if (metric.meta.hiddenOnMobile) {
return 'hidden md:block' return 'hidden md:block'
} else { } else {
return '' return ''
@ -199,11 +201,11 @@ export default function ListReport(props) {
const metricLabels = getAvailableMetrics().map((metric) => { const metricLabels = getAvailableMetrics().map((metric) => {
return ( return (
<div <div
key={metric.name} key={metric.key}
className={`text-right ${hiddenOnMobileClass(metric)}`} className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }} style={{ minWidth: colMinWidth }}
> >
{metricLabelFor(metric, props.query)} { metric.renderLabel(props.query) }
</div> </div>
) )
}) })
@ -235,21 +237,10 @@ export default function ListReport(props) {
) )
} }
function getFilterQuery(listItem) {
const prefixAndFilter = props.getFilterFor(listItem)
if (!prefixAndFilter) { return null }
const {prefix, filter, labels} = prefixAndFilter
const newFilters = replaceFilterByPrefix(props.query, prefix, filter)
const newLabels = cleanLabels(newFilters, props.query.labels, filter[1], labels)
return updatedQuery({ filters: newFilters, labels: newLabels })
}
function renderBarFor(listItem) { function renderBarFor(listItem) {
const lightBackground = props.color || 'bg-green-50' const lightBackground = props.color || 'bg-green-50'
const noop = () => { } const noop = () => { }
const metricToPlot = metrics.find(m => m.plot).name const metricToPlot = metrics.find(metric => metric.meta.plot).key
return ( return (
<div className="flex-grow w-full overflow-hidden"> <div className="flex-grow w-full overflow-hidden">
@ -260,7 +251,12 @@ export default function ListReport(props) {
plot={metricToPlot} plot={metricToPlot}
> >
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full"> <div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
<FilterLink filterQuery={getFilterQuery(listItem)} onClick={props.onClick || noop}> <FilterLink
query={props.query}
filterInfo={props.getFilterFor(listItem)}
onClick={props.onClick || noop}
extraClass="max-w-max w-full flex items-center md:overflow-hidden"
>
{maybeRenderIconFor(listItem)} {maybeRenderIconFor(listItem)}
<span className="w-full md:truncate"> <span className="w-full md:truncate">
@ -284,12 +280,12 @@ export default function ListReport(props) {
return getAvailableMetrics().map((metric) => { return getAvailableMetrics().map((metric) => {
return ( return (
<div <div
key={`${listItem.name}__${metric.name}`} key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`} className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }} style={{ width: colMinWidth, minWidth: colMinWidth }}
> >
<span className="font-medium text-sm dark:text-gray-200 text-right"> <span className="font-medium text-sm dark:text-gray-200 text-right">
{displayMetricValue(listItem[metric.name], metric)} { metric.renderValue(listItem[metric.key]) }
</span> </span>
</div> </div>
) )

View File

@ -1,5 +1,5 @@
import { hasGoalFilter } from "../../util/filters" import { hasGoalFilter } from "../../util/filters"
import numberFormatter from "../../util/number-formatter" import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter"
import React from "react" import React from "react"
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
@ -14,42 +14,151 @@ function maybeRequire() {
const Money = maybeRequire().default const Money = maybeRequire().default
export const VISITORS_METRIC = { // Class representation of a metric.
name: 'visitors',
label: 'Visitors',
realtimeLabel: 'Current visitors',
goalFilterLabel: 'Conversions',
plot: true
}
export const PERCENTAGE_METRIC = { name: 'percentage', label: '%' }
export const CR_METRIC = { name: 'conversion_rate', label: 'CR' }
export function maybeWithCR(metrics, query) { // Metric instances can be created directly via the Metric constructor,
if (metrics.includes(PERCENTAGE_METRIC) && hasGoalFilter(query)) { // or using special creator functions like `createVisitors`, which just
return metrics.filter((m) => { return m !== PERCENTAGE_METRIC }).concat([CR_METRIC]) // fill out the known fields for that metric.
}
else if (hasGoalFilter(query)) { // ### Required props
return metrics.concat(CR_METRIC)
} // * `key` - the key under which to read values under in an API
else {
return metrics // * `renderValue` - a function that takes a value of this metric, and
// and returns the "rendered" version of it. Can be JSX or a string.
// * `renderLabel` - a function rendering a label for this metric given a
// query argument. Can return JSX or string.
// ### Optional props
// * `meta` - a map with extra context for this metric. E.g. `plot`, or
// `hiddenOnMobile` define some special behaviours in the context where
// it's used.
export class Metric {
constructor(props) {
if (!props.key) {
throw Error("Required field `key` is missing")
}
if (typeof props.renderLabel !== 'function') {
throw Error("Required field `renderLabel` should be a function")
}
if (typeof props.renderValue !== 'function') {
throw Error("Required field `renderValue` should be a function")
}
this.key = props.key
this.renderValue = props.renderValue
this.renderLabel = props.renderLabel
this.meta = props.meta || {}
} }
} }
export function displayMetricValue(value, metric) { // Creates a Metric class representing the `visitors` metric.
if (['total_revenue', 'average_revenue'].includes(metric.name)) {
return <Money formatted={value} /> // Optional props for conveniently generating the `renderLabel` function:
} else if (metric === PERCENTAGE_METRIC) {
return value // * `defaultLabel` - label when not realtime, and no goal filter applied
} else if (metric === CR_METRIC) { // * `realtimeLabel` - label when realtime period
return `${value}%` // * `goalFilterLabel` - label when goal filter is applied
export const createVisitors = (props) => {
let renderValue
if (typeof props.renderValue === 'function') {
renderValue = props.renderValue
} else { } else {
return <span tooltip={value}>{numberFormatter(value)}</span> renderValue = renderNumberWithTooltip
} }
let renderLabel
if (typeof props.renderLabel === 'function') {
renderLabel = props.renderLabel
} else {
renderLabel = (query) => {
const defaultLabel = props.defaultLabel || 'Visitors'
const realtimeLabel = props.realtimeLabel || 'Current visitors'
const goalFilterLabel = props.goalFilterLabel || 'Conversions'
if (query.period === 'realtime') { return realtimeLabel }
if (query && hasGoalFilter(query)) { return goalFilterLabel }
return defaultLabel
}
}
return new Metric({...props, key: "visitors", renderValue, renderLabel})
} }
export function metricLabelFor(metric, query) { export const createConversionRate = (props) => {
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel } const renderValue = percentageFormatter
if (metric.goalFilterLabel && hasGoalFilter(query)) { return metric.goalFilterLabel } const renderLabel = (_query) => "CR"
return metric.label return new Metric({...props, key: "conversion_rate", renderLabel, renderValue})
} }
export const createPercentage = (props) => {
const renderValue = (value) => value
const renderLabel = (_query) => "%"
return new Metric({...props, key: "percentage", renderLabel, renderValue})
}
export const createEvents = (props) => {
const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip
return new Metric({...props, key: "events", renderValue: renderValue})
}
export const createTotalRevenue = (props) => {
const renderValue = (value) => <Money formatted={value} />
const renderLabel = (_query) => "Revenue"
return new Metric({...props, key: "total_revenue", renderValue, renderLabel})
}
export const createAverageRevenue = (props) => {
const renderValue = (value) => <Money formatted={value} />
const renderLabel = (_query) => "Average"
return new Metric({...props, key: "average_revenue", renderValue, renderLabel})
}
export const createTotalVisitors = (props) => {
const renderValue = renderNumberWithTooltip
const renderLabel = (_query) => "Total Visitors"
return new Metric({...props, key: "total_visitors", renderValue, renderLabel})
}
export const createVisits = (props) => {
const renderValue = renderNumberWithTooltip
return new Metric({...props, key: "visits", renderValue})
}
export const createVisitDuration = (props) => {
const renderValue = durationFormatter
const renderLabel = (_query) => "Visit Duration"
return new Metric({...props, key: "visit_duration", renderValue, renderLabel})
}
export const createBounceRate = (props) => {
const renderValue = (value) => `${value}%`
const renderLabel = (_query) => "Bounce Rate"
return new Metric({...props, key: "bounce_rate", renderValue, renderLabel})
}
export const createPageviews = (props) => {
const renderValue = renderNumberWithTooltip
const renderLabel = (_query) => "Pageviews"
return new Metric({...props, key: "pageviews", renderValue, renderLabel})
}
export const createTimeOnPage = (props) => {
const renderValue = durationFormatter
const renderLabel = (_query) => "Time on Page"
return new Metric({...props, key: "time_on_page", renderValue, renderLabel})
}
export const createExitRate = (props) => {
const renderValue = percentageFormatter
const renderLabel = (_query) => "Exit Rate"
return new Metric({...props, key: "exit_rate", renderValue, renderLabel})
}
function renderNumberWithTooltip(value) {
return <span tooltip={value}>{numberFormatter(value)}</span>
}

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import * as api from '../../api' import * as api from '../../api'
import * as url from '../../util/url' import * as url from '../../util/url'
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics' import * as metrics from '../reports/metrics'
import { hasGoalFilter } from "../../util/filters"
import ListReport from '../reports/list' import ListReport from '../reports/list'
import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning' import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning'
@ -44,6 +45,13 @@ export default function Referrers({ source, site, query }) {
) )
} }
function chooseMetrics() {
return [
metrics.createVisitors({meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<div className="flex flex-col flex-grow"> <div className="flex flex-col flex-grow">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
@ -55,7 +63,7 @@ export default function Referrers({ source, site, query }) {
afterFetchData={afterFetchReferrers} afterFetchData={afterFetchReferrers}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Referrer" keyLabel="Referrer"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)} detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)}
query={query} query={query}
externalLinkDest={externalLinkDest} externalLinkDest={externalLinkDest}

View File

@ -4,7 +4,8 @@ import * as storage from '../../util/storage'
import * as url from '../../util/url' import * as url from '../../util/url'
import * as api from '../../api' import * as api from '../../api'
import ListReport from '../reports/list' import ListReport from '../reports/list'
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics'; import * as metrics from '../reports/metrics';
import { hasGoalFilter } from "../../util/filters"
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames' import classNames from 'classnames'
@ -41,13 +42,20 @@ function AllSources(props) {
) )
} }
function chooseMetrics() {
return [
metrics.createVisitors({meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={props.afterFetchData} afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel="Source" keyLabel="Source"
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath('sources')} detailsLink={url.sitePath('sources')}
renderIcon={renderIcon} renderIcon={renderIcon}
query={query} query={query}
@ -71,13 +79,20 @@ function UTMSources(props) {
} }
} }
function chooseMetrics() {
return [
metrics.createVisitors({meta: {plot: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
return ( return (
<ListReport <ListReport
fetchData={fetchData} fetchData={fetchData}
afterFetchData={props.afterFetchData} afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel={utmTag.label} keyLabel={utmTag.label}
metrics={maybeWithCR([VISITORS_METRIC], query)} metrics={chooseMetrics()}
detailsLink={url.sitePath(utmTag.endpoint)} detailsLink={url.sitePath(utmTag.endpoint)}
query={query} query={query}
color="bg-blue-50" color="bg-blue-50"

View File

@ -37,26 +37,26 @@ defmodule PlausibleWeb.AdminControllerTest do
} do } do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
s1 = insert(:site) s1 = insert(:site, inserted_at: ~N[2024-01-01 00:00:00])
insert_list(3, :site_membership, site: s1) insert_list(3, :site_membership, site: s1)
s2 = insert(:site) s2 = insert(:site, inserted_at: ~N[2024-01-02 00:00:00])
insert_list(3, :site_membership, site: s2) insert_list(3, :site_membership, site: s2)
s3 = insert(:site) s3 = insert(:site, inserted_at: ~N[2024-01-03 00:00:00])
insert_list(3, :site_membership, site: s3) insert_list(3, :site_membership, site: s3)
conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"}) conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"})
page1_html = html_response(conn1, 200) page1_html = html_response(conn1, 200)
assert page1_html =~ s1.domain assert page1_html =~ s3.domain
assert page1_html =~ s2.domain assert page1_html =~ s2.domain
refute page1_html =~ s3.domain refute page1_html =~ s1.domain
conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"}) conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"})
page2_html = html_response(conn2, 200) page2_html = html_response(conn2, 200)
refute page2_html =~ s1.domain refute page2_html =~ s3.domain
refute page2_html =~ s2.domain refute page2_html =~ s2.domain
assert page2_html =~ s3.domain assert page2_html =~ s1.domain
end end
end end

View File

@ -2385,10 +2385,11 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do
] ]
}) })
assert json_response(conn, 200)["results"] == [ results = json_response(conn, 200)["results"]
%{"dimensions" => ["/plausible.io"], "metrics" => [100]},
%{"dimensions" => ["/important-page"], "metrics" => [100]} assert length(results) == 2
] assert %{"dimensions" => ["/plausible.io"], "metrics" => [100]} in results
assert %{"dimensions" => ["/important-page"], "metrics" => [100]} in results
end end
test "IN filter for event:name", %{conn: conn, site: site} do test "IN filter for event:name", %{conn: conn, site: site} do