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
// the function does not run on the initial render.
@ -12,4 +12,16 @@ export function useMountedEffect(fn, deps) {
mounted.current = true
}
}, 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 { useMountedEffect } from './custom-hooks';
import Historical from './historical'
import Realtime from './realtime'
import {parseQuery} from './query'
import * as api from './api'
import withQueryContext from './components/query-context-hoc';
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) {
const { location, site, loggedIn, currentUserRole } = 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])
const {
site,
loggedIn,
currentUserRole,
query,
importedDataInView,
updateImportedDataInView,
lastLoadTimestamp
} = props
if (query.period === 'realtime') {
return (
@ -50,10 +37,10 @@ function Dashboard(props) {
query={query}
lastLoadTimestamp={lastLoadTimestamp}
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 * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
import { getFiltersByKeyPrefix } from './util/filters'
import dayjs from 'dayjs';
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) {
// if we update any data that we store in localstorage, make sure going back in history will
// 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) {
const { query, history, to, className, children } = props

View File

@ -28,7 +28,7 @@ function Realtime(props) {
<Datepicker site={site} query={query} />
</div>
</div>
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp} />
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
<div className="w-full md:flex">
<div className={ statsBoxClass }>
<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 EntryPagesModal from './stats/modals/entry-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 ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
import * as url from './util/url';
function ScrollToTop() {
const location = useLocation();
@ -51,14 +50,8 @@ export default function Router({ site, loggedIn, currentUserRole }) {
<Route path="/exit-pages">
<ExitPagesModal site={site} />
</Route>
<Route path="/countries">
<ModalTable title="Top countries" site={site} endpoint={url.apiPath(site, '/countries')} filterKey="country" keyLabel="Country" renderIcon={renderCountryIcon} showPercentage={true} />
</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 exact path={["/countries", "/regions", "/cities"]}>
<LocationsModal site={site} />
</Route>
<Route path="/custom-prop-values/:prop_key">
<PropsModal site={site} />
@ -74,15 +67,3 @@ export default function Router({ site, loggedIn, currentUserRole }) {
</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 url from '../../util/url'
import { CR_METRIC } from '../reports/metrics';
import * as metrics from '../reports/metrics';
import ListReport from '../reports/list';
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*/
return (
<ListReport
@ -27,13 +37,7 @@ export default function Conversions(props) {
getFilterFor={getFilterFor}
keyLabel="Goal"
onClick={props.onGoalFilterClick}
metrics={[
{ 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 }
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath('conversions')}
maybeHideDetails={true}
query={query}

View File

@ -1,7 +1,7 @@
import React from "react"
import Conversions from './conversions'
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 api from "../../api"
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 (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel={prop}
metrics={[
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
CR_METRIC
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
externalLinkDest={externalLinkDest()}
maybeHideDetails={true}

View File

@ -1,9 +1,9 @@
import React, { useCallback, useEffect, useState } from "react"
import ListReport, { MIN_HEIGHT } from "../reports/list";
import Combobox from '../../components/combobox'
import * as metrics from '../reports/metrics'
import * as api from '../../api'
import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage";
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters"
import classNames from "classnames";
@ -82,8 +82,19 @@ export default function Properties(props) {
setPropKey(newPropKey)
}
}
/*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() {
return (
<ListReport
@ -91,13 +102,7 @@ export default function Properties(props) {
afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={[
{ 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 }
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
maybeHideDetails={true}
query={query}

View File

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

View File

@ -1,41 +1,27 @@
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) {
const isRealtime = query.period === 'realtime'
const goalFilter = getGoalFilter(query)
const hasPageFilter = getFiltersByKeyPrefix(query, "page").length > 0
const isGoalFilter = hasGoalFilter(query)
const isPageFilter = getFiltersByKeyPrefix(query, "page").length > 0
if (isRealtime && goalFilter) {
if (isRealtime && isGoalFilter) {
return ["visitors"]
} else if (isRealtime) {
return ["visitors", "pageviews"]
} else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) {
} else if (isGoalFilter && revenueAvailable(query, site)) {
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
} else if (goalFilter) {
} else if (isGoalFilter) {
return ["visitors", "events", "conversion_rate"]
} else if (hasPageFilter) {
} else if (isPageFilter) {
return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"]
} else {
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 = {
'visitors': 'Visitors',
'pageviews': 'Pageviews',

View File

@ -136,7 +136,14 @@ export default function VisitorGraph(props) {
{(topStatsLoading || graphLoading) && renderLoader()}
<FadeIn show={!(topStatsLoading || graphLoading)}>
<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 className="relative px-2">
{graphRefreshing && renderLoader()}

View File

@ -6,7 +6,8 @@ import CountriesMap from './map'
import * as api from '../../api'
import { apiPath, sitePath } from '../../util/url'
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 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 (
<ListReport
fetchData={fetchData}
@ -34,7 +42,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
getFilterFor={getFilterFor}
onClick={onClick}
keyLabel="Country"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={sitePath('countries')}
query={query}
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 (
<ListReport
fetchData={fetchData}
@ -67,7 +82,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
getFilterFor={getFilterFor}
onClick={onClick}
keyLabel="Region"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={sitePath('regions')}
query={query}
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 (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel="City"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={sitePath('cities')}
query={query}
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 { Link } from 'react-router-dom'
import React, { useCallback, useState } from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { replaceFilterByPrefix } from '../../util/filters'
import withQueryContext from "../../components/query-context-hoc";
import BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
/*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) {
const site = props.site
const query = parseQuery(props.location.search, site)
const { site, query } = props
const [showRevenue, setShowRevenue] = useState(false)
const [loading, setLoading] = useState(true)
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const [page, setPage] = useState(1)
const [list, setList] = useState([])
const reportInfo = {
title: 'Goal Conversions',
dimension: 'goal',
endpoint: '/conversions',
dimensionLabel: "Goal"
}
useEffect(() => {
fetchData()
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}, [])
function fetchData() {
api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page })
.then((response) => {
setLoading(false)
setList(list.concat(response.results))
setPage(page + 1)
setMoreResultsAvailable(response.results.length >= 100)
})
function chooseMetrics() {
return [
metrics.createVisitors({renderLabel: (_query) => "Uniques"}),
metrics.createEvents({renderLabel: (_query) => "Total"}),
metrics.createConversionRate(),
showRevenue && metrics.createAverageRevenue(),
showRevenue && metrics.createTotalRevenue(),
].filter(metric => !!metric)
}
function loadMore() {
setLoading(true)
fetchData()
}
// After a successful API response, we want to scan the rows of the
// response and update the internal `showRevenue` state, which decides
// whether revenue metrics are passed into BreakdownModal in `metrics`.
const afterFetchData = useCallback((res) => {
setShowRevenue(revenueInResponse(res))
}, [showRevenue])
function renderLoadMore() {
return (
<div className="w-full text-center my-4">
<button onClick={loadMore} type="button" className="button">
Load more
</button>
</div>
)
}
// After fetching the next page, we never want to set `showRevenue` to
// `false` as revenue metrics might exist in previously loaded data.
const afterFetchNextPage = useCallback((res) => {
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
}, [showRevenue])
function filterSearchLink(listItem) {
const filters = replaceFilterByPrefix(query, "goal", ["is", "goal", [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">
{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>
</>
)
function revenueInResponse(apiResponse) {
return apiResponse.results.some((item) => item.total_revenue)
}
return (
<Modal>
{renderBody()}
{loading && renderLoading()}
{!loading && moreResultsAvailable && renderLoadMore()}
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
afterFetchData={BUILD_EXTRA ? afterFetchData : undefined}
afterFetchNextPage={BUILD_EXTRA ? afterFetchNextPage : undefined}
getFilterInfo={getFilterInfo}
searchEnabled={false}
/>
</Modal>
)
}
export default withRouter(ConversionsModal)
export default withRouter(withQueryContext(ConversionsModal))

View File

@ -1,158 +1,67 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import React, {useCallback} from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { trimURL, updatedQuery } from '../../util/url'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
import { hasGoalFilter } from "../../util/filters";
import { addFilter } from '../../query'
import BreakdownModal from "./breakdown-modal";
import * as metrics from '../reports/metrics'
import withQueryContext from "../../components/query-context-hoc";
class EntryPagesModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site),
pages: [],
page: 1,
moreResultsAvailable: false
function EntryPagesModal(props) {
const { site, query } = props
const reportInfo = {
title: 'Entry Pages',
dimension: 'entry_page',
endpoint: '/entry-pages',
dimensionLabel: 'Entry page'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}
}, [])
componentDidMount() {
this.loadPages();
}
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [])
loadPages() {
const { query, page } = this.state;
api.get(
`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`,
query,
{ 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'
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (this.showConversionRate()) {
return 'Conversions'
if (query.period === 'realtime') {
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) {
const filters = replaceFilterByPrefix(this.state.query, "entry_page", ["is", "entry_page", [page.name]])
return (
<tr className="text-sm dark:text-gray-200" key={page.name}>
<td className="p-2 truncate">
<Link
to={{
pathname: `/`,
search: updatedQuery({ filters })
}}
className="hover:underline"
>
{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>
)
}
return (
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
/>
</Modal>
)
}
export default withRouter(EntryPagesModal)
export default withRouter(withQueryContext(EntryPagesModal))

View File

@ -1,135 +1,67 @@
import React from "react";
import { Link } from 'react-router-dom'
import React, {useCallback} from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { percentageFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { trimURL, updatedQuery } from '../../util/url'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
class ExitPagesModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site),
pages: [],
page: 1,
moreResultsAvailable: false
import { hasGoalFilter } from "../../util/filters";
import { addFilter } from '../../query'
import BreakdownModal from "./breakdown-modal";
import * as metrics from '../reports/metrics'
import withQueryContext from "../../components/query-context-hoc";
function ExitPagesModal(props) {
const { site, query } = props
const reportInfo = {
title: 'Exit Pages',
dimension: 'exit_page',
endpoint: '/exit-pages',
dimensionLabel: 'Page url'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}
}, [])
componentDidMount() {
this.loadPages();
}
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [])
loadPages() {
const { query, page } = this.state;
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page })
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 })))
}
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'
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (this.showConversionRate()) {
return 'Conversions'
if (query.period === 'realtime') {
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) {
const filters = replaceFilterByPrefix(this.state.query, "exit_page", ["is", "exit_page", [page.name]])
return (
<tr className="text-sm dark:text-gray-200" key={page.name}>
<td className="p-2 truncate">
<Link
to={{
pathname: `/`,
search: updatedQuery({ filters })
}}
className="hover:underline"
>
{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>
)
}
return (
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
/>
</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 { Link } from 'react-router-dom'
import React, {useCallback} from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { trimURL, updatedQuery } from '../../util/url'
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
import { hasGoalFilter } from "../../util/filters";
import { addFilter } from '../../query'
import BreakdownModal from "./breakdown-modal";
import * as metrics from '../reports/metrics'
import withQueryContext from "../../components/query-context-hoc";
class PagesModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site),
pages: [],
page: 1,
moreResultsAvailable: false
function PagesModal(props) {
const { site, query } = props
const reportInfo = {
title: 'Top Pages',
dimension: 'page',
endpoint: '/pages',
dimensionLabel: 'Page url'
}
const getFilterInfo = useCallback((listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
}
}
}, [])
componentDidMount() {
this.loadPages();
}
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
}, [])
loadPages() {
const detailed = this.showExtra()
const { query, page } = this.state;
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed })
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 })))
}
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'
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (this.showConversionRate()) {
return 'Conversions'
if (query.period === 'realtime') {
return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
}
return 'Visitors'
return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createPageviews(),
metrics.createBounceRate(),
metrics.createTimeOnPage()
]
}
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">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>
)
}
return (
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
/>
</Modal>
)
}
export default withRouter(PagesModal)
export default withRouter(withQueryContext(PagesModal))

View File

@ -1,137 +1,63 @@
import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom'
import React, { useCallback } from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query'
import withQueryContext from "../../components/query-context-hoc";
import { addFilter } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { EVENT_PROPS_PREFIX, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
import { EVENT_PROPS_PREFIX, hasGoalFilter } from "../../util/filters"
import BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
import { revenueAvailable } from "../../query";
function PropsModal(props) {
const site = props.site
const query = parseQuery(props.location.search, site)
const {site, query, location} = props
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 [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const [page, setPage] = useState(1)
const [list, setList] = useState([])
const reportInfo = {
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
dimension: propKey,
endpoint: `/custom-prop-values/${propKey}`,
dimensionLabel: propKey
}
useEffect(() => {
fetchData()
const getFilterInfo = useCallback((listItem) => {
return {
prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
}
}, [])
function fetchData() {
api.get(url.apiPath(site, `/custom-prop-values/${propKey}`), query, { limit: 100, page })
.then((response) => {
setLoading(false)
setList(list.concat(response.results))
setPage(page + 1)
setMoreResultsAvailable(response.results.length >= 100)
})
}
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]])
}, [])
function loadMore() {
setLoading(true)
fetchData()
}
function renderLoadMore() {
return (
<div className="w-full text-center my-4">
<button onClick={loadMore} type="button" className="button">
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>
</>
)
function chooseMetrics() {
return [
metrics.createVisitors({renderLabel: (_query) => "Visitors"}),
metrics.createEvents({renderLabel: (_query) => "Events"}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage(),
showRevenueMetrics && metrics.createAverageRevenue(),
showRevenueMetrics && metrics.createTotalRevenue(),
].filter(metric => !!metric)
}
return (
<Modal>
{renderBody()}
{loading && renderLoading()}
{!loading && moreResultsAvailable && renderLoadMore()}
<Modal site={site}>
<BreakdownModal
site={site}
query={query}
reportInfo={reportInfo}
metrics={chooseMetrics()}
getFilterInfo={getFilterInfo}
addSearchFilter={addSearchFilter}
/>
</Modal>
)
}
export default withRouter(PropsModal)
export default withRouter(withQueryContext(PropsModal))

View File

@ -1,147 +1,85 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import React, { useCallback } from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { updatedQuery } from "../../util/url";
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
import withQueryContext from "../../components/query-context-hoc";
import { hasGoalFilter } from "../../util/filters";
import BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
import { addFilter } from "../../query";
class ReferrerDrilldownModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site)
function ReferrerDrilldownModal(props) {
const { site, query, match } = props
const reportInfo = {
title: "Referrer Drilldown",
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 detailed = this.showExtra()
const addSearchFilter = useCallback((query, searchString) => {
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 })
.then((response) => this.setState({ loading: false, referrers: response.results }))
}
showExtra() {
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
}
showConversionRate() {
return hasGoalFilter(this.state.query)
}
label() {
if (this.state.query.period === 'realtime') {
return 'Current visitors'
function chooseMetrics() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createConversionRate()
]
}
if (this.showConversionRate()) {
return 'Conversions'
if (query.period === 'realtime') {
return [
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
]
}
return 'Visitors'
return [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createBounceRate(),
metrics.createVisitDuration()
]
}
formatBounceRate(ref) {
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]])
const renderIcon = useCallback((listItem) => {
return (
<span className="flex group items-center">
<img src={`/favicon/sources/${referrer.name}`} referrerPolicy="no-referrer" className="h-4 w-4 mr-2 inline" />
<Link
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>
<img
src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
className="h-4 w-4 mr-2 align-middle inline"
/>
)
}
}, [])
renderReferrer(referrer) {
return (
<tr className="text-sm dark:text-gray-200" key={referrer.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>
)
const getExternalLinkURL = useCallback((listItem) => {
if (listItem.name !== "Direct / None") {
return '//' + listItem.name
}
}
}, [])
render() {
return (
<Modal>
{this.renderBody()}
</Modal>
)
}
return (
<Modal site={site}>
<BreakdownModal
site={site}
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 { Link, withRouter } from 'react-router-dom'
import React, { useCallback } from "react";
import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { updatedQuery } from "../../util/url";
import { FILTER_OPERATIONS, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
import withQueryContext from "../../components/query-context-hoc";
import { hasGoalFilter } from "../../util/filters";
import BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
import { addFilter } from "../../query";
const TITLES = {
sources: 'Top Sources',
utm_mediums: 'Top UTM mediums',
utm_sources: 'Top UTM sources',
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') {
const VIEWS = {
sources: {
info: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'},
renderIcon: (listItem) => {
return (
<img
src={`/favicon/sources/${encodeURIComponent(source.name)}`}
src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
className="h-4 w-4 mr-2 align-middle inline"
/>
)
}
}
renderSource(source) {
const filters = replaceFilterByPrefix(this.state.query, this.filterKey(), [FILTER_OPERATIONS.is, this.filterKey(), [source.name]])
return (
<tr className="text-sm dark:text-gray-200" key={source.name}>
<td className="p-2">
{this.icon(source)}
<Link className="hover:underline" to={{ search: updatedQuery({ filters }), pathname: '/' + encodeURIComponent(this.props.site.domain) }}>{source.name}</Link>
</td>
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.total_visitors)}</td>}
<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>}
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(source)}</td>}
{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>
)
}
},
utm_mediums: {
info: {title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium'}
},
utm_sources: {
info: {title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source'}
},
utm_campaigns: {
info: {title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign'}
},
utm_contents: {
info: {title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content'}
},
utm_terms: {
info: {title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term'}
},
}
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 api from '../../api'
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 { hasGoalFilter } from '../../util/filters';
function EntryPages({ query, site, afterFetchData }) {
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 (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel="Entry page"
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: 'Unique Entrances' }], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath('entry-pages')}
query={query}
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 (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel="Exit page"
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: "Unique Exits" }], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath('exit-pages')}
query={query}
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 (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel="Page"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath('pages')}
query={query}
externalLinkDest={externalLinkDest}

View File

@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move';
import { displayMetricValue, metricLabelFor } from './metrics';
import FadeIn from '../../fade-in'
import MoreLink from '../more-link'
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 COL_MIN_WIDTH = 70
function FilterLink({ filterQuery, onClick, children }) {
const className = classNames('max-w-max w-full flex items-center md:overflow-hidden', {
'hover:underline': !!filterQuery
})
export function FilterLink({ pathname, query, filterInfo, onClick, children, extraClass }) {
const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo })
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 (
<Link
to={{ search: filterQuery.toString() }}
onClick={onClick}
className={className}
>
<Link to={linkTo} onClick={onClick} className={className}>
{children}
</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
// 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
// column values are read. For example:
// column values are read, and how they're rendered. For example:
// | keyLabel | METRIC_1.label | METRIC_2.label | ...
// |--------------------|---------------------------|---------------------------|-----
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ...
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ...
// | keyLabel | METRIC_1.renderLabel(query) | METRIC_1.renderLabel(query) | ...
// |--------------------|-----------------------------|-----------------------------| ---
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ...
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ...
// 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
// object containing a `results` key.
// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at
// least the `name` and the `label` keys. If the metric should have a different label
// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`.
// * `metrics` - a list `Metric` class objects, containing at least the `key,`
// `renderLabel`, and `renderValue` fields. Optionally, a Metric object can contain
// 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]
// 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.
function getAvailableMetrics() {
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) {
if (metric.hiddenOnMobile) {
if (metric.meta.hiddenOnMobile) {
return 'hidden md:block'
} else {
return ''
@ -199,11 +201,11 @@ export default function ListReport(props) {
const metricLabels = getAvailableMetrics().map((metric) => {
return (
<div
key={metric.name}
className={`text-right ${hiddenOnMobileClass(metric)}`}
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metricLabelFor(metric, props.query)}
{ metric.renderLabel(props.query) }
</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) {
const lightBackground = props.color || 'bg-green-50'
const noop = () => { }
const metricToPlot = metrics.find(m => m.plot).name
const metricToPlot = metrics.find(metric => metric.meta.plot).key
return (
<div className="flex-grow w-full overflow-hidden">
@ -260,7 +251,12 @@ export default function ListReport(props) {
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">
<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)}
<span className="w-full md:truncate">
@ -284,12 +280,12 @@ export default function ListReport(props) {
return getAvailableMetrics().map((metric) => {
return (
<div
key={`${listItem.name}__${metric.name}`}
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`}
style={{ width: colMinWidth, minWidth: colMinWidth }}
>
<span className="font-medium text-sm dark:text-gray-200 text-right">
{displayMetricValue(listItem[metric.name], metric)}
{ metric.renderValue(listItem[metric.key]) }
</span>
</div>
)

View File

@ -1,5 +1,5 @@
import { hasGoalFilter } from "../../util/filters"
import numberFormatter from "../../util/number-formatter"
import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter"
import React from "react"
/*global BUILD_EXTRA*/
@ -14,42 +14,151 @@ function maybeRequire() {
const Money = maybeRequire().default
export const VISITORS_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' }
// Class representation of a metric.
export function maybeWithCR(metrics, query) {
if (metrics.includes(PERCENTAGE_METRIC) && hasGoalFilter(query)) {
return metrics.filter((m) => { return m !== PERCENTAGE_METRIC }).concat([CR_METRIC])
}
else if (hasGoalFilter(query)) {
return metrics.concat(CR_METRIC)
}
else {
return metrics
// Metric instances can be created directly via the Metric constructor,
// or using special creator functions like `createVisitors`, which just
// fill out the known fields for that metric.
// ### Required props
// * `key` - the key under which to read values under in an API
// * `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) {
if (['total_revenue', 'average_revenue'].includes(metric.name)) {
return <Money formatted={value} />
} else if (metric === PERCENTAGE_METRIC) {
return value
} else if (metric === CR_METRIC) {
return `${value}%`
// Creates a Metric class representing the `visitors` metric.
// Optional props for conveniently generating the `renderLabel` function:
// * `defaultLabel` - label when not realtime, and no goal filter applied
// * `realtimeLabel` - label when realtime period
// * `goalFilterLabel` - label when goal filter is applied
export const createVisitors = (props) => {
let renderValue
if (typeof props.renderValue === 'function') {
renderValue = props.renderValue
} 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) {
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel }
if (metric.goalFilterLabel && hasGoalFilter(query)) { return metric.goalFilterLabel }
return metric.label
export const createConversionRate = (props) => {
const renderValue = percentageFormatter
const renderLabel = (_query) => "CR"
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 * as api from '../../api'
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 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 (
<div className="flex flex-col flex-grow">
<div className="flex gap-x-1">
@ -55,7 +63,7 @@ export default function Referrers({ source, site, query }) {
afterFetchData={afterFetchReferrers}
getFilterFor={getFilterFor}
keyLabel="Referrer"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)}
query={query}
externalLinkDest={externalLinkDest}

View File

@ -4,7 +4,8 @@ import * as storage from '../../util/storage'
import * as url from '../../util/url'
import * as api from '../../api'
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 { ChevronDownIcon } from '@heroicons/react/20/solid'
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 (
<ListReport
fetchData={fetchData}
afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor}
keyLabel="Source"
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath('sources')}
renderIcon={renderIcon}
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 (
<ListReport
fetchData={fetchData}
afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor}
keyLabel={utmTag.label}
metrics={maybeWithCR([VISITORS_METRIC], query)}
metrics={chooseMetrics()}
detailsLink={url.sitePath(utmTag.endpoint)}
query={query}
color="bg-blue-50"

View File

@ -37,26 +37,26 @@ defmodule PlausibleWeb.AdminControllerTest do
} do
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)
s2 = insert(:site)
s2 = insert(:site, inserted_at: ~N[2024-01-02 00:00:00])
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)
conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"})
page1_html = html_response(conn1, 200)
assert page1_html =~ s1.domain
assert page1_html =~ s3.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"})
page2_html = html_response(conn2, 200)
refute page2_html =~ s1.domain
refute page2_html =~ s3.domain
refute page2_html =~ s2.domain
assert page2_html =~ s3.domain
assert page2_html =~ s1.domain
end
end

View File

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