mirror of
https://github.com/plausible/analytics.git
synced 2024-08-17 05:40:24 +03:00
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:
parent
a9676546dc
commit
7d0321fd22
50
assets/js/dashboard/components/query-context-hoc.js
Normal file
50
assets/js/dashboard/components/query-context-hoc.js
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
@ -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))
|
||||
|
@ -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
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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',
|
||||
|
@ -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()}
|
||||
|
@ -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}
|
||||
|
306
assets/js/dashboard/stats/modals/breakdown-modal.js
Normal file
306
assets/js/dashboard/stats/modals/breakdown-modal.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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))
|
||||
|
@ -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))
|
||||
|
@ -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))
|
||||
|
73
assets/js/dashboard/stats/modals/locations-modal.js
Normal file
73
assets/js/dashboard/stats/modals/locations-modal.js
Normal 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))
|
@ -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))
|
||||
|
@ -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))
|
||||
|
@ -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))
|
||||
|
@ -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))
|
||||
|
@ -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)
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
}
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user