mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +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
|
// A custom hook that behaves like `useEffect`, but
|
||||||
// the function does not run on the initial render.
|
// the function does not run on the initial render.
|
||||||
@ -12,4 +12,16 @@ export function useMountedEffect(fn, deps) {
|
|||||||
mounted.current = true
|
mounted.current = true
|
||||||
}
|
}
|
||||||
}, deps)
|
}, deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom hook that debounces the function calls by
|
||||||
|
// a given delay. Cancels all function calls that have
|
||||||
|
// a following call within `delay_ms`.
|
||||||
|
export function useDebouncedEffect(fn, deps, delay_ms) {
|
||||||
|
const callback = useCallback(fn, deps)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(callback, delay_ms)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [callback, delay_ms])
|
||||||
}
|
}
|
@ -1,35 +1,22 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react'
|
||||||
import { withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import { useMountedEffect } from './custom-hooks';
|
|
||||||
import Historical from './historical'
|
import Historical from './historical'
|
||||||
import Realtime from './realtime'
|
import Realtime from './realtime'
|
||||||
import {parseQuery} from './query'
|
import withQueryContext from './components/query-context-hoc';
|
||||||
import * as api from './api'
|
|
||||||
|
|
||||||
export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded"
|
export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded"
|
||||||
|
|
||||||
function Dashboard(props) {
|
function Dashboard(props) {
|
||||||
const { location, site, loggedIn, currentUserRole } = props
|
const {
|
||||||
const [query, setQuery] = useState(parseQuery(location.search, site))
|
site,
|
||||||
const [importedDataInView, setImportedDataInView] = useState(false)
|
loggedIn,
|
||||||
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
|
currentUserRole,
|
||||||
const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) }
|
query,
|
||||||
|
importedDataInView,
|
||||||
useEffect(() => {
|
updateImportedDataInView,
|
||||||
document.addEventListener('tick', updateLastLoadTimestamp)
|
lastLoadTimestamp
|
||||||
|
} = props
|
||||||
return () => {
|
|
||||||
document.removeEventListener('tick', updateLastLoadTimestamp)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useMountedEffect(() => {
|
|
||||||
api.cancelAll()
|
|
||||||
setQuery(parseQuery(location.search, site))
|
|
||||||
updateLastLoadTimestamp()
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
|
|
||||||
if (query.period === 'realtime') {
|
if (query.period === 'realtime') {
|
||||||
return (
|
return (
|
||||||
@ -50,10 +37,10 @@ function Dashboard(props) {
|
|||||||
query={query}
|
query={query}
|
||||||
lastLoadTimestamp={lastLoadTimestamp}
|
lastLoadTimestamp={lastLoadTimestamp}
|
||||||
importedDataInView={importedDataInView}
|
importedDataInView={importedDataInView}
|
||||||
updateImportedDataInView={setImportedDataInView}
|
updateImportedDataInView={updateImportedDataInView}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(Dashboard)
|
export default withRouter(withQueryContext(Dashboard))
|
||||||
|
@ -5,6 +5,7 @@ import { PlausibleSearchParams, updatedQuery } from './util/url'
|
|||||||
import { nowForSite } from './util/date'
|
import { nowForSite } from './util/date'
|
||||||
import * as storage from './util/storage'
|
import * as storage from './util/storage'
|
||||||
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
|
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
|
||||||
|
import { getFiltersByKeyPrefix } from './util/filters'
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
@ -47,6 +48,10 @@ export function parseQuery(querystring, site) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addFilter(query, filter) {
|
||||||
|
return {...query, filters: [...query.filters, filter]}
|
||||||
|
}
|
||||||
|
|
||||||
export function navigateToQuery(history, queryFrom, newData) {
|
export function navigateToQuery(history, queryFrom, newData) {
|
||||||
// if we update any data that we store in localstorage, make sure going back in history will
|
// if we update any data that we store in localstorage, make sure going back in history will
|
||||||
// revert them
|
// revert them
|
||||||
@ -133,6 +138,26 @@ export function filtersBackwardsCompatibilityRedirect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a boolean indicating whether the given query includes a
|
||||||
|
// non-empty goal filterset containing a single, or multiple revenue
|
||||||
|
// goals with the same currency. Used to decide whether to render
|
||||||
|
// revenue metrics in a dashboard report or not.
|
||||||
|
export function revenueAvailable(query, site) {
|
||||||
|
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
|
||||||
|
const goalFilters = getFiltersByKeyPrefix(query, "goal")
|
||||||
|
|
||||||
|
return goalFilters.some(([_op, _key, clauses]) => {
|
||||||
|
return clauses.includes(rg.event_name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const singleCurrency = revenueGoalsInFilter.every((rg) => {
|
||||||
|
return rg.currency === revenueGoalsInFilter[0].currency
|
||||||
|
})
|
||||||
|
|
||||||
|
return revenueGoalsInFilter.length > 0 && singleCurrency
|
||||||
|
}
|
||||||
|
|
||||||
function QueryLink(props) {
|
function QueryLink(props) {
|
||||||
const { query, history, to, className, children } = props
|
const { query, history, to, className, children } = props
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ function Realtime(props) {
|
|||||||
<Datepicker site={site} query={query} />
|
<Datepicker site={site} query={query} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp} />
|
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
|
||||||
<div className="w-full md:flex">
|
<div className="w-full md:flex">
|
||||||
<div className={ statsBoxClass }>
|
<div className={ statsBoxClass }>
|
||||||
<Sources site={site} query={query} />
|
<Sources site={site} query={query} />
|
||||||
|
@ -8,11 +8,10 @@ import GoogleKeywordsModal from './stats/modals/google-keywords'
|
|||||||
import PagesModal from './stats/modals/pages'
|
import PagesModal from './stats/modals/pages'
|
||||||
import EntryPagesModal from './stats/modals/entry-pages'
|
import EntryPagesModal from './stats/modals/entry-pages'
|
||||||
import ExitPagesModal from './stats/modals/exit-pages'
|
import ExitPagesModal from './stats/modals/exit-pages'
|
||||||
import ModalTable from './stats/modals/table'
|
import LocationsModal from './stats/modals/locations-modal';
|
||||||
import PropsModal from './stats/modals/props'
|
import PropsModal from './stats/modals/props'
|
||||||
import ConversionsModal from './stats/modals/conversions'
|
import ConversionsModal from './stats/modals/conversions'
|
||||||
import FilterModal from './stats/modals/filter-modal'
|
import FilterModal from './stats/modals/filter-modal'
|
||||||
import * as url from './util/url';
|
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -51,14 +50,8 @@ export default function Router({ site, loggedIn, currentUserRole }) {
|
|||||||
<Route path="/exit-pages">
|
<Route path="/exit-pages">
|
||||||
<ExitPagesModal site={site} />
|
<ExitPagesModal site={site} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/countries">
|
<Route exact path={["/countries", "/regions", "/cities"]}>
|
||||||
<ModalTable title="Top countries" site={site} endpoint={url.apiPath(site, '/countries')} filterKey="country" keyLabel="Country" renderIcon={renderCountryIcon} showPercentage={true} />
|
<LocationsModal site={site} />
|
||||||
</Route>
|
|
||||||
<Route path="/regions">
|
|
||||||
<ModalTable title="Top regions" site={site} endpoint={url.apiPath(site, '/regions')} filterKey="region" keyLabel="Region" renderIcon={renderRegionIcon} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/cities">
|
|
||||||
<ModalTable title="Top cities" site={site} endpoint={url.apiPath(site, '/cities')} filterKey="city" keyLabel="City" renderIcon={renderCityIcon} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/custom-prop-values/:prop_key">
|
<Route path="/custom-prop-values/:prop_key">
|
||||||
<PropsModal site={site} />
|
<PropsModal site={site} />
|
||||||
@ -74,15 +67,3 @@ export default function Router({ site, loggedIn, currentUserRole }) {
|
|||||||
</BrowserRouter >
|
</BrowserRouter >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCityIcon(city) {
|
|
||||||
return <span className="mr-1">{city.country_flag}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCountryIcon(country) {
|
|
||||||
return <span className="mr-1">{country.flag}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRegionIcon(region) {
|
|
||||||
return <span className="mr-1">{region.country_flag}</span>
|
|
||||||
}
|
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
|
|
||||||
import { CR_METRIC } from '../reports/metrics';
|
import * as metrics from '../reports/metrics';
|
||||||
import ListReport from '../reports/list';
|
import ListReport from '../reports/list';
|
||||||
|
|
||||||
export default function Conversions(props) {
|
export default function Conversions(props) {
|
||||||
@ -19,6 +19,16 @@ export default function Conversions(props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: {plot: true}}),
|
||||||
|
metrics.createEvents({renderLabel: (_query) => "Total", meta: {hiddenOnMobile: true}}),
|
||||||
|
metrics.createConversionRate(),
|
||||||
|
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
|
||||||
|
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
@ -27,13 +37,7 @@ export default function Conversions(props) {
|
|||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Goal"
|
keyLabel="Goal"
|
||||||
onClick={props.onGoalFilterClick}
|
onClick={props.onGoalFilterClick}
|
||||||
metrics={[
|
metrics={chooseMetrics()}
|
||||||
{ name: 'visitors', label: "Uniques", plot: true },
|
|
||||||
{ name: 'events', label: "Total", hiddenOnMobile: true },
|
|
||||||
CR_METRIC,
|
|
||||||
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
|
|
||||||
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
|
|
||||||
]}
|
|
||||||
detailsLink={url.sitePath('conversions')}
|
detailsLink={url.sitePath('conversions')}
|
||||||
maybeHideDetails={true}
|
maybeHideDetails={true}
|
||||||
query={query}
|
query={query}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import Conversions from './conversions'
|
import Conversions from './conversions'
|
||||||
import ListReport from "../reports/list"
|
import ListReport from "../reports/list"
|
||||||
import { CR_METRIC } from "../reports/metrics"
|
import * as metrics from '../reports/metrics'
|
||||||
import * as url from "../../util/url"
|
import * as url from "../../util/url"
|
||||||
import * as api from "../../api"
|
import * as api from "../../api"
|
||||||
import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters"
|
import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters"
|
||||||
@ -53,17 +53,21 @@ function SpecialPropBreakdown(props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
|
||||||
|
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
|
||||||
|
metrics.createConversionRate()
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel={prop}
|
keyLabel={prop}
|
||||||
metrics={[
|
metrics={chooseMetrics()}
|
||||||
{ name: 'visitors', label: 'Visitors', plot: true },
|
|
||||||
{ name: 'events', label: 'Events', hiddenOnMobile: true },
|
|
||||||
CR_METRIC
|
|
||||||
]}
|
|
||||||
detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
|
detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
|
||||||
externalLinkDest={externalLinkDest()}
|
externalLinkDest={externalLinkDest()}
|
||||||
maybeHideDetails={true}
|
maybeHideDetails={true}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react"
|
import React, { useCallback, useEffect, useState } from "react"
|
||||||
import ListReport, { MIN_HEIGHT } from "../reports/list";
|
import ListReport, { MIN_HEIGHT } from "../reports/list";
|
||||||
import Combobox from '../../components/combobox'
|
import Combobox from '../../components/combobox'
|
||||||
|
import * as metrics from '../reports/metrics'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
|
|
||||||
import * as storage from "../../util/storage";
|
import * as storage from "../../util/storage";
|
||||||
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters"
|
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters"
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@ -82,8 +82,19 @@ export default function Properties(props) {
|
|||||||
setPropKey(newPropKey)
|
setPropKey(newPropKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
|
||||||
|
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage(),
|
||||||
|
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
|
||||||
|
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
function renderBreakdown() {
|
function renderBreakdown() {
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
@ -91,13 +102,7 @@ export default function Properties(props) {
|
|||||||
afterFetchData={props.afterFetchData}
|
afterFetchData={props.afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel={propKey}
|
keyLabel={propKey}
|
||||||
metrics={[
|
metrics={chooseMetrics()}
|
||||||
{ name: 'visitors', label: 'Visitors', plot: true },
|
|
||||||
{ name: 'events', label: 'Events', hiddenOnMobile: true },
|
|
||||||
hasGoalFilter(query) ? CR_METRIC : PERCENTAGE_METRIC,
|
|
||||||
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
|
|
||||||
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
|
|
||||||
]}
|
|
||||||
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
|
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
|
||||||
maybeHideDetails={true}
|
maybeHideDetails={true}
|
||||||
query={query}
|
query={query}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import * as storage from '../../util/storage'
|
import * as storage from '../../util/storage'
|
||||||
import { getFiltersByKeyPrefix, isFilteringOnFixedValue } from '../../util/filters'
|
import { getFiltersByKeyPrefix, hasGoalFilter, isFilteringOnFixedValue } from '../../util/filters'
|
||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
|
import * as metrics from '../reports/metrics'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import { VISITORS_METRIC, PERCENTAGE_METRIC, maybeWithCR } from '../reports/metrics';
|
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
||||||
|
|
||||||
// Icons copied from https://github.com/alrra/browser-logos
|
// Icons copied from https://github.com/alrra/browser-logos
|
||||||
@ -55,13 +55,21 @@ function Browsers({ query, site, afterFetchData }) {
|
|||||||
return browserIconFor(listItem.name)
|
return browserIconFor(listItem.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage()
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Browser"
|
keyLabel="Browser"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
/>
|
/>
|
||||||
@ -92,13 +100,21 @@ function BrowserVersions({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage()
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Browser version"
|
keyLabel="Browser version"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
@ -148,6 +164,14 @@ function OperatingSystems({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage({meta: {hiddenonMobile: true}})
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
function renderIcon(listItem) {
|
function renderIcon(listItem) {
|
||||||
return osIconFor(listItem.name)
|
return osIconFor(listItem.name)
|
||||||
}
|
}
|
||||||
@ -159,7 +183,7 @@ function OperatingSystems({ query, site, afterFetchData }) {
|
|||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
keyLabel="Operating system"
|
keyLabel="Operating system"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -189,6 +213,14 @@ function OperatingSystemVersions({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage()
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
@ -196,7 +228,7 @@ function OperatingSystemVersions({ query, site, afterFetchData }) {
|
|||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Operating System Version"
|
keyLabel="Operating System Version"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -221,13 +253,21 @@ function ScreenSizes({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
!hasGoalFilter(query) && metrics.createPercentage()
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Screen size"
|
keyLabel="Screen size"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
/>
|
/>
|
||||||
|
@ -1,41 +1,27 @@
|
|||||||
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||||
import { getFiltersByKeyPrefix, getGoalFilter } from '../../util/filters'
|
import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters'
|
||||||
|
import { revenueAvailable } from '../../query'
|
||||||
|
|
||||||
export function getGraphableMetrics(query, site) {
|
export function getGraphableMetrics(query, site) {
|
||||||
const isRealtime = query.period === 'realtime'
|
const isRealtime = query.period === 'realtime'
|
||||||
const goalFilter = getGoalFilter(query)
|
const isGoalFilter = hasGoalFilter(query)
|
||||||
const hasPageFilter = getFiltersByKeyPrefix(query, "page").length > 0
|
const isPageFilter = getFiltersByKeyPrefix(query, "page").length > 0
|
||||||
|
|
||||||
if (isRealtime && goalFilter) {
|
if (isRealtime && isGoalFilter) {
|
||||||
return ["visitors"]
|
return ["visitors"]
|
||||||
} else if (isRealtime) {
|
} else if (isRealtime) {
|
||||||
return ["visitors", "pageviews"]
|
return ["visitors", "pageviews"]
|
||||||
} else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) {
|
} else if (isGoalFilter && revenueAvailable(query, site)) {
|
||||||
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
|
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
|
||||||
} else if (goalFilter) {
|
} else if (isGoalFilter) {
|
||||||
return ["visitors", "events", "conversion_rate"]
|
return ["visitors", "events", "conversion_rate"]
|
||||||
} else if (hasPageFilter) {
|
} else if (isPageFilter) {
|
||||||
return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"]
|
return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"]
|
||||||
} else {
|
} else {
|
||||||
return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"]
|
return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue metrics can only be graphed if:
|
|
||||||
// * The query is filtered by at least one revenue goal
|
|
||||||
// * All revenue goals in filter have the same currency
|
|
||||||
function canGraphRevenueMetrics([_operation, _filterKey, clauses], site) {
|
|
||||||
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
|
|
||||||
return clauses.includes(rg.event_name)
|
|
||||||
})
|
|
||||||
|
|
||||||
const singleCurrency = revenueGoalsInFilter.every((rg) => {
|
|
||||||
return rg.currency === revenueGoalsInFilter[0].currency
|
|
||||||
})
|
|
||||||
|
|
||||||
return revenueGoalsInFilter.length > 0 && singleCurrency
|
|
||||||
}
|
|
||||||
|
|
||||||
export const METRIC_LABELS = {
|
export const METRIC_LABELS = {
|
||||||
'visitors': 'Visitors',
|
'visitors': 'Visitors',
|
||||||
'pageviews': 'Pageviews',
|
'pageviews': 'Pageviews',
|
||||||
|
@ -136,7 +136,14 @@ export default function VisitorGraph(props) {
|
|||||||
{(topStatsLoading || graphLoading) && renderLoader()}
|
{(topStatsLoading || graphLoading) && renderLoader()}
|
||||||
<FadeIn show={!(topStatsLoading || graphLoading)}>
|
<FadeIn show={!(topStatsLoading || graphLoading)}>
|
||||||
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}>
|
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}>
|
||||||
<TopStats site={site} query={query} data={topStatData} onMetricUpdate={onMetricUpdate} tooltipBoundary={topStatsBoundary.current} lastLoadTimestamp={lastLoadTimestamp} />
|
<TopStats
|
||||||
|
site={site}
|
||||||
|
query={query}
|
||||||
|
data={topStatData}
|
||||||
|
onMetricUpdate={onMetricUpdate}
|
||||||
|
tooltipBoundary={topStatsBoundary.current}
|
||||||
|
lastLoadTimestamp={lastLoadTimestamp}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative px-2">
|
<div className="relative px-2">
|
||||||
{graphRefreshing && renderLoader()}
|
{graphRefreshing && renderLoader()}
|
||||||
|
@ -6,7 +6,8 @@ import CountriesMap from './map'
|
|||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import { apiPath, sitePath } from '../../util/url'
|
import { apiPath, sitePath } from '../../util/url'
|
||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics';
|
import * as metrics from '../reports/metrics';
|
||||||
|
import { hasGoalFilter } from "../../util/filters"
|
||||||
import { getFiltersByKeyPrefix } from '../../util/filters';
|
import { getFiltersByKeyPrefix } from '../../util/filters';
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
||||||
|
|
||||||
@ -27,6 +28,13 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
@ -34,7 +42,7 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
keyLabel="Country"
|
keyLabel="Country"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={sitePath('countries')}
|
detailsLink={sitePath('countries')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
@ -60,6 +68,13 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
@ -67,7 +82,7 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
keyLabel="Region"
|
keyLabel="Region"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={sitePath('regions')}
|
detailsLink={sitePath('regions')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
@ -93,13 +108,20 @@ function Cities({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="City"
|
keyLabel="City"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={sitePath('cities')}
|
detailsLink={sitePath('cities')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
|
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 React, { useCallback, useState } from "react";
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
import * as url from "../../util/url";
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import numberFormatter from '../../util/number-formatter'
|
import * as metrics from "../reports/metrics";
|
||||||
import { parseQuery } from '../../query'
|
|
||||||
import { replaceFilterByPrefix } from '../../util/filters'
|
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
/*global require*/
|
|
||||||
function maybeRequire() {
|
|
||||||
if (BUILD_EXTRA) {
|
|
||||||
return require('../../extra/money')
|
|
||||||
} else {
|
|
||||||
return { default: null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Money = maybeRequire().default
|
|
||||||
|
|
||||||
function ConversionsModal(props) {
|
function ConversionsModal(props) {
|
||||||
const site = props.site
|
const { site, query } = props
|
||||||
const query = parseQuery(props.location.search, site)
|
const [showRevenue, setShowRevenue] = useState(false)
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const reportInfo = {
|
||||||
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
|
title: 'Goal Conversions',
|
||||||
const [page, setPage] = useState(1)
|
dimension: 'goal',
|
||||||
const [list, setList] = useState([])
|
endpoint: '/conversions',
|
||||||
|
dimensionLabel: "Goal"
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
fetchData()
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function fetchData() {
|
function chooseMetrics() {
|
||||||
api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page })
|
return [
|
||||||
.then((response) => {
|
metrics.createVisitors({renderLabel: (_query) => "Uniques"}),
|
||||||
setLoading(false)
|
metrics.createEvents({renderLabel: (_query) => "Total"}),
|
||||||
setList(list.concat(response.results))
|
metrics.createConversionRate(),
|
||||||
setPage(page + 1)
|
showRevenue && metrics.createAverageRevenue(),
|
||||||
setMoreResultsAvailable(response.results.length >= 100)
|
showRevenue && metrics.createTotalRevenue(),
|
||||||
})
|
].filter(metric => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
// After a successful API response, we want to scan the rows of the
|
||||||
setLoading(true)
|
// response and update the internal `showRevenue` state, which decides
|
||||||
fetchData()
|
// whether revenue metrics are passed into BreakdownModal in `metrics`.
|
||||||
}
|
const afterFetchData = useCallback((res) => {
|
||||||
|
setShowRevenue(revenueInResponse(res))
|
||||||
|
}, [showRevenue])
|
||||||
|
|
||||||
function renderLoadMore() {
|
// After fetching the next page, we never want to set `showRevenue` to
|
||||||
return (
|
// `false` as revenue metrics might exist in previously loaded data.
|
||||||
<div className="w-full text-center my-4">
|
const afterFetchNextPage = useCallback((res) => {
|
||||||
<button onClick={loadMore} type="button" className="button">
|
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
|
||||||
Load more
|
}, [showRevenue])
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterSearchLink(listItem) {
|
function revenueInResponse(apiResponse) {
|
||||||
const filters = replaceFilterByPrefix(query, "goal", ["is", "goal", [listItem.name]])
|
return apiResponse.results.some((item) => item.total_revenue)
|
||||||
return url.updatedQuery({ filters })
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderListItem(listItem, hasRevenue) {
|
|
||||||
return (
|
|
||||||
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
|
|
||||||
<td className="p-2">
|
|
||||||
<Link
|
|
||||||
to={{ pathname: '/', search: filterSearchLink(listItem) }}
|
|
||||||
className="hover:underline block truncate">
|
|
||||||
{listItem.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
|
|
||||||
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
|
|
||||||
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLoading() {
|
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody() {
|
|
||||||
const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Goal Conversions</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300"></div>
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">Goal</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Uniques</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>
|
|
||||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
|
|
||||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{list.map((item) => renderListItem(item, hasRevenue))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal>
|
<Modal site={site}>
|
||||||
{renderBody()}
|
<BreakdownModal
|
||||||
{loading && renderLoading()}
|
site={site}
|
||||||
{!loading && moreResultsAvailable && renderLoadMore()}
|
query={query}
|
||||||
|
reportInfo={reportInfo}
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
afterFetchData={BUILD_EXTRA ? afterFetchData : undefined}
|
||||||
|
afterFetchNextPage={BUILD_EXTRA ? afterFetchNextPage : undefined}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
searchEnabled={false}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(ConversionsModal)
|
export default withRouter(withQueryContext(ConversionsModal))
|
||||||
|
@ -1,158 +1,67 @@
|
|||||||
import React from "react";
|
import React, {useCallback} from "react";
|
||||||
import { Link, withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import { hasGoalFilter } from "../../util/filters";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import { addFilter } from '../../query'
|
||||||
import { parseQuery } from '../../query'
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import { trimURL, updatedQuery } from '../../util/url'
|
import * as metrics from '../reports/metrics'
|
||||||
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
|
|
||||||
class EntryPagesModal extends React.Component {
|
function EntryPagesModal(props) {
|
||||||
constructor(props) {
|
const { site, query } = props
|
||||||
super(props)
|
|
||||||
this.state = {
|
const reportInfo = {
|
||||||
loading: true,
|
title: 'Entry Pages',
|
||||||
query: parseQuery(props.location.search, props.site),
|
dimension: 'entry_page',
|
||||||
pages: [],
|
endpoint: '/entry-pages',
|
||||||
page: 1,
|
dimensionLabel: 'Entry page'
|
||||||
moreResultsAvailable: false
|
}
|
||||||
|
|
||||||
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
componentDidMount() {
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
this.loadPages();
|
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
loadPages() {
|
function chooseMetrics() {
|
||||||
const { query, page } = this.state;
|
if (hasGoalFilter(query)) {
|
||||||
|
return [
|
||||||
api.get(
|
metrics.createTotalVisitors(),
|
||||||
`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`,
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
||||||
query,
|
metrics.createConversionRate()
|
||||||
{ limit: 100, page }
|
]
|
||||||
)
|
|
||||||
.then(
|
|
||||||
(response) => this.setState((state) => ({
|
|
||||||
loading: false,
|
|
||||||
pages: state.pages.concat(response.results),
|
|
||||||
moreResultsAvailable: response.results.length === 100
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMore() {
|
|
||||||
const { page } = this.state;
|
|
||||||
this.setState({ loading: true, page: page + 1 }, this.loadPages.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBounceRate(page) {
|
|
||||||
if (typeof (page.bounce_rate) === 'number') {
|
|
||||||
return `${page.bounce_rate}%`;
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
showConversionRate() {
|
|
||||||
return hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
showExtra() {
|
|
||||||
return this.state.query.period !== 'realtime' && !this.showConversionRate()
|
|
||||||
}
|
|
||||||
|
|
||||||
label() {
|
|
||||||
if (this.state.query.period === 'realtime') {
|
|
||||||
return 'Current visitors'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showConversionRate()) {
|
if (query.period === 'realtime') {
|
||||||
return 'Conversions'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Visitors'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
|
||||||
|
metrics.createVisits({renderLabel: (_query) => "Total Entrances" }),
|
||||||
|
metrics.createVisitDuration()
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPage(page) {
|
return (
|
||||||
const filters = replaceFilterByPrefix(this.state.query, "entry_page", ["is", "entry_page", [page.name]])
|
<Modal site={site}>
|
||||||
return (
|
<BreakdownModal
|
||||||
<tr className="text-sm dark:text-gray-200" key={page.name}>
|
site={site}
|
||||||
<td className="p-2 truncate">
|
query={query}
|
||||||
<Link
|
reportInfo={reportInfo}
|
||||||
to={{
|
metrics={chooseMetrics()}
|
||||||
pathname: `/`,
|
getFilterInfo={getFilterInfo}
|
||||||
search: updatedQuery({ filters })
|
addSearchFilter={addSearchFilter}
|
||||||
}}
|
/>
|
||||||
className="hover:underline"
|
</Modal>
|
||||||
>
|
)
|
||||||
{trimURL(page.name, 40)}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
|
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{durationFormatter(page.visit_duration)}</td>}
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLoading() {
|
|
||||||
if (this.state.loading) {
|
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
|
||||||
} else if (this.state.moreResultsAvailable) {
|
|
||||||
return (
|
|
||||||
<div className="w-full text-center my-4">
|
|
||||||
<button onClick={this.loadMore.bind(this)} type="button" className="button">
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
if (this.state.pages) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Entry Pages</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300"></div>
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
|
||||||
align="left"
|
|
||||||
>Page url
|
|
||||||
</th>
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Visitors </th>}
|
|
||||||
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >{this.label()} </th>
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Entrances </th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Visit Duration </th>}
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >CR </th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{this.state.pages.map(this.renderPage.bind(this))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Modal>
|
|
||||||
{this.renderBody()}
|
|
||||||
{this.renderLoading()}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(EntryPagesModal)
|
export default withRouter(withQueryContext(EntryPagesModal))
|
||||||
|
@ -1,135 +1,67 @@
|
|||||||
import React from "react";
|
import React, {useCallback} from "react";
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import { hasGoalFilter } from "../../util/filters";
|
||||||
import numberFormatter, { percentageFormatter } from '../../util/number-formatter'
|
import { addFilter } from '../../query'
|
||||||
import { parseQuery } from '../../query'
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import { trimURL, updatedQuery } from '../../util/url'
|
import * as metrics from '../reports/metrics'
|
||||||
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
class ExitPagesModal extends React.Component {
|
|
||||||
constructor(props) {
|
function ExitPagesModal(props) {
|
||||||
super(props)
|
const { site, query } = props
|
||||||
this.state = {
|
|
||||||
loading: true,
|
const reportInfo = {
|
||||||
query: parseQuery(props.location.search, props.site),
|
title: 'Exit Pages',
|
||||||
pages: [],
|
dimension: 'exit_page',
|
||||||
page: 1,
|
endpoint: '/exit-pages',
|
||||||
moreResultsAvailable: false
|
dimensionLabel: 'Page url'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
componentDidMount() {
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
this.loadPages();
|
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
loadPages() {
|
function chooseMetrics() {
|
||||||
const { query, page } = this.state;
|
if (hasGoalFilter(query)) {
|
||||||
|
return [
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page })
|
metrics.createTotalVisitors(),
|
||||||
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 })))
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
||||||
}
|
metrics.createConversionRate()
|
||||||
|
]
|
||||||
loadMore() {
|
|
||||||
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
showConversionRate() {
|
|
||||||
return hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
showExtra() {
|
|
||||||
return this.state.query.period !== 'realtime' && !this.showConversionRate()
|
|
||||||
}
|
|
||||||
|
|
||||||
label() {
|
|
||||||
if (this.state.query.period === 'realtime') {
|
|
||||||
return 'Current visitors'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showConversionRate()) {
|
if (query.period === 'realtime') {
|
||||||
return 'Conversions'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Visitors'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
|
||||||
|
metrics.createVisits({renderLabel: (_query) => "Total Exits" }),
|
||||||
|
metrics.createExitRate()
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPage(page) {
|
return (
|
||||||
const filters = replaceFilterByPrefix(this.state.query, "exit_page", ["is", "exit_page", [page.name]])
|
<Modal site={site}>
|
||||||
return (
|
<BreakdownModal
|
||||||
<tr className="text-sm dark:text-gray-200" key={page.name}>
|
site={site}
|
||||||
<td className="p-2 truncate">
|
query={query}
|
||||||
<Link
|
reportInfo={reportInfo}
|
||||||
to={{
|
metrics={chooseMetrics()}
|
||||||
pathname: `/`,
|
getFilterInfo={getFilterInfo}
|
||||||
search: updatedQuery({ filters })
|
addSearchFilter={addSearchFilter}
|
||||||
}}
|
/>
|
||||||
className="hover:underline"
|
</Modal>
|
||||||
>
|
)
|
||||||
{trimURL(page.name, 40)}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
|
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{percentageFormatter(page.exit_rate)}</td>}
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLoading() {
|
|
||||||
if (this.state.loading) {
|
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
|
||||||
} else if (this.state.moreResultsAvailable) {
|
|
||||||
return (
|
|
||||||
<div className="w-full text-center my-4">
|
|
||||||
<button onClick={this.loadMore.bind(this)} type="button" className="button">
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
if (this.state.pages) {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Exit Pages</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300"></div>
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Page url</th>
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right" >Total Visitors </th>}
|
|
||||||
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total Exits</th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Exit Rate</th>}
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{this.state.pages.map(this.renderPage.bind(this))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Modal>
|
|
||||||
{this.renderBody()}
|
|
||||||
{this.renderLoading()}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(ExitPagesModal)
|
export default withRouter(withQueryContext(ExitPagesModal))
|
||||||
|
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 React, {useCallback} from "react";
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import { hasGoalFilter } from "../../util/filters";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import { addFilter } from '../../query'
|
||||||
import { parseQuery } from '../../query'
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import { trimURL, updatedQuery } from '../../util/url'
|
import * as metrics from '../reports/metrics'
|
||||||
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
|
|
||||||
class PagesModal extends React.Component {
|
function PagesModal(props) {
|
||||||
constructor(props) {
|
const { site, query } = props
|
||||||
super(props)
|
|
||||||
this.state = {
|
const reportInfo = {
|
||||||
loading: true,
|
title: 'Top Pages',
|
||||||
query: parseQuery(props.location.search, props.site),
|
dimension: 'page',
|
||||||
pages: [],
|
endpoint: '/pages',
|
||||||
page: 1,
|
dimensionLabel: 'Page url'
|
||||||
moreResultsAvailable: false
|
}
|
||||||
|
|
||||||
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
componentDidMount() {
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
this.loadPages();
|
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
loadPages() {
|
function chooseMetrics() {
|
||||||
const detailed = this.showExtra()
|
if (hasGoalFilter(query)) {
|
||||||
const { query, page } = this.state;
|
return [
|
||||||
|
metrics.createTotalVisitors(),
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed })
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
||||||
.then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 })))
|
metrics.createConversionRate()
|
||||||
}
|
]
|
||||||
|
|
||||||
loadMore() {
|
|
||||||
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
showExtra() {
|
|
||||||
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
showPageviews() {
|
|
||||||
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
showConversionRate() {
|
|
||||||
return hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBounceRate(page) {
|
|
||||||
if (typeof (page.bounce_rate) === 'number') {
|
|
||||||
return page.bounce_rate + '%'
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderPage(page) {
|
|
||||||
const filters = replaceFilterByPrefix(this.state.query, "page", ["is", "page", [page.name]])
|
|
||||||
const timeOnPage = page['time_on_page'] ? durationFormatter(page['time_on_page']) : '-';
|
|
||||||
return (
|
|
||||||
<tr className="text-sm dark:text-gray-200" key={page.name}>
|
|
||||||
<td className="p-2">
|
|
||||||
<Link
|
|
||||||
to={{
|
|
||||||
pathname: `/`,
|
|
||||||
search: updatedQuery({ filters })
|
|
||||||
}}
|
|
||||||
className="hover:underline block truncate"
|
|
||||||
>
|
|
||||||
{trimURL(page.name, 50)}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{page.total_visitors}</td>}
|
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
|
|
||||||
{this.showPageviews() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.pageviews)}</td>}
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td>}
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{timeOnPage}</td>}
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{page.conversion_rate}%</td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
label() {
|
|
||||||
if (this.state.query.period === 'realtime') {
|
|
||||||
return 'Current visitors'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showConversionRate()) {
|
if (query.period === 'realtime') {
|
||||||
return 'Conversions'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Visitors'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
|
||||||
|
metrics.createPageviews(),
|
||||||
|
metrics.createBounceRate(),
|
||||||
|
metrics.createTimeOnPage()
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoading() {
|
return (
|
||||||
if (this.state.loading) {
|
<Modal site={site}>
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
<BreakdownModal
|
||||||
} else if (this.state.moreResultsAvailable) {
|
site={site}
|
||||||
return (
|
query={query}
|
||||||
<div className="w-full text-center my-4">
|
reportInfo={reportInfo}
|
||||||
<button onClick={this.loadMore.bind(this)} type="button" className="button">
|
metrics={chooseMetrics()}
|
||||||
Load more
|
getFilterInfo={getFilterInfo}
|
||||||
</button>
|
addSearchFilter={addSearchFilter}
|
||||||
</div>
|
/>
|
||||||
)
|
</Modal>
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
if (this.state.pages) {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Top Pages</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300"></div>
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Page url</th>
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
|
|
||||||
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
|
|
||||||
{this.showPageviews() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Pageviews</th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Time on Page</th>}
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{this.state.pages.map(this.renderPage.bind(this))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Modal>
|
|
||||||
{this.renderBody()}
|
|
||||||
{this.renderLoading()}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(PagesModal)
|
export default withRouter(withQueryContext(PagesModal))
|
||||||
|
@ -1,137 +1,63 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
import * as url from "../../util/url";
|
import { addFilter } from '../../query'
|
||||||
import numberFormatter from '../../util/number-formatter'
|
|
||||||
import { parseQuery } from '../../query'
|
|
||||||
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
|
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
|
||||||
import { EVENT_PROPS_PREFIX, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"
|
import { EVENT_PROPS_PREFIX, hasGoalFilter } from "../../util/filters"
|
||||||
|
import BreakdownModal from "./breakdown-modal";
|
||||||
/*global BUILD_EXTRA*/
|
import * as metrics from "../reports/metrics";
|
||||||
/*global require*/
|
import { revenueAvailable } from "../../query";
|
||||||
function maybeRequire() {
|
|
||||||
if (BUILD_EXTRA) {
|
|
||||||
return require('../../extra/money')
|
|
||||||
} else {
|
|
||||||
return { default: null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Money = maybeRequire().default
|
|
||||||
|
|
||||||
function PropsModal(props) {
|
function PropsModal(props) {
|
||||||
const site = props.site
|
const {site, query, location} = props
|
||||||
const query = parseQuery(props.location.search, site)
|
const propKey = location.pathname.split('/').filter(i => i).pop()
|
||||||
|
|
||||||
const propKey = props.location.pathname.split('/').filter(i => i).pop()
|
/*global BUILD_EXTRA*/
|
||||||
|
const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site)
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const reportInfo = {
|
||||||
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
|
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
|
||||||
const [page, setPage] = useState(1)
|
dimension: propKey,
|
||||||
const [list, setList] = useState([])
|
endpoint: `/custom-prop-values/${propKey}`,
|
||||||
|
dimensionLabel: propKey
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
fetchData()
|
return {
|
||||||
|
prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
|
||||||
|
filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function fetchData() {
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
api.get(url.apiPath(site, `/custom-prop-values/${propKey}`), query, { limit: 100, page })
|
return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]])
|
||||||
.then((response) => {
|
}, [])
|
||||||
setLoading(false)
|
|
||||||
setList(list.concat(response.results))
|
|
||||||
setPage(page + 1)
|
|
||||||
setMoreResultsAvailable(response.results.length >= 100)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore() {
|
function chooseMetrics() {
|
||||||
setLoading(true)
|
return [
|
||||||
fetchData()
|
metrics.createVisitors({renderLabel: (_query) => "Visitors"}),
|
||||||
}
|
metrics.createEvents({renderLabel: (_query) => "Events"}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
function renderLoadMore() {
|
!hasGoalFilter(query) && metrics.createPercentage(),
|
||||||
return (
|
showRevenueMetrics && metrics.createAverageRevenue(),
|
||||||
<div className="w-full text-center my-4">
|
showRevenueMetrics && metrics.createTotalRevenue(),
|
||||||
<button onClick={loadMore} type="button" className="button">
|
].filter(metric => !!metric)
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterSearchLink(listItem) {
|
|
||||||
const filters = replaceFilterByPrefix(query, EVENT_PROPS_PREFIX, ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]])
|
|
||||||
return url.updatedQuery({ filters })
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderListItem(listItem, hasRevenue) {
|
|
||||||
return (
|
|
||||||
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
|
|
||||||
<td className="p-2">
|
|
||||||
<Link
|
|
||||||
to={{ pathname: '/', search: filterSearchLink(listItem) }}
|
|
||||||
className="hover:underline block truncate">
|
|
||||||
{url.trimURL(listItem.name, 30)}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
|
|
||||||
{
|
|
||||||
hasGoalFilter(query) ? (
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
|
|
||||||
) : (
|
|
||||||
<td className="p-2 w-24 font-medium" align="right">{listItem.percentage}</td>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
|
|
||||||
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLoading() {
|
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody() {
|
|
||||||
const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">{specialTitleWhenGoalFilter(query, 'Custom Property Breakdown')}</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300"></div>
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">{propKey}</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Events</th>
|
|
||||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{hasGoalFilter(query) ? 'CR' : '%'}</th>
|
|
||||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
|
|
||||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{list.map((item) => renderListItem(item, hasRevenue))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal>
|
<Modal site={site}>
|
||||||
{renderBody()}
|
<BreakdownModal
|
||||||
{loading && renderLoading()}
|
site={site}
|
||||||
{!loading && moreResultsAvailable && renderLoadMore()}
|
query={query}
|
||||||
|
reportInfo={reportInfo}
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
addSearchFilter={addSearchFilter}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(PropsModal)
|
export default withRouter(withQueryContext(PropsModal))
|
||||||
|
@ -1,147 +1,85 @@
|
|||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Link, withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import { hasGoalFilter } from "../../util/filters";
|
||||||
import { parseQuery } from '../../query'
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import { updatedQuery } from "../../util/url";
|
import * as metrics from "../reports/metrics";
|
||||||
import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
|
import { addFilter } from "../../query";
|
||||||
|
|
||||||
class ReferrerDrilldownModal extends React.Component {
|
function ReferrerDrilldownModal(props) {
|
||||||
constructor(props) {
|
const { site, query, match } = props
|
||||||
super(props)
|
|
||||||
this.state = {
|
const reportInfo = {
|
||||||
loading: true,
|
title: "Referrer Drilldown",
|
||||||
query: parseQuery(props.location.search, props.site)
|
dimension: 'referrer',
|
||||||
|
endpoint: `/referrers/${match.params.referrer}`,
|
||||||
|
dimensionLabel: "Referrer"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ['is', reportInfo.dimension, [listItem.name]]
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
componentDidMount() {
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
const detailed = this.showExtra()
|
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
|
||||||
|
}, [])
|
||||||
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.match.params.referrer}`, this.state.query, { limit: 100, detailed })
|
function chooseMetrics() {
|
||||||
.then((response) => this.setState({ loading: false, referrers: response.results }))
|
if (hasGoalFilter(query)) {
|
||||||
}
|
return [
|
||||||
|
metrics.createTotalVisitors(),
|
||||||
showExtra() {
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
||||||
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
|
metrics.createConversionRate()
|
||||||
}
|
]
|
||||||
|
|
||||||
showConversionRate() {
|
|
||||||
return hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
label() {
|
|
||||||
if (this.state.query.period === 'realtime') {
|
|
||||||
return 'Current visitors'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showConversionRate()) {
|
if (query.period === 'realtime') {
|
||||||
return 'Conversions'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Visitors'
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
|
||||||
|
metrics.createBounceRate(),
|
||||||
|
metrics.createVisitDuration()
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
formatBounceRate(ref) {
|
const renderIcon = useCallback((listItem) => {
|
||||||
if (typeof (ref.bounce_rate) === 'number') {
|
|
||||||
return ref.bounce_rate + '%'
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatDuration(referrer) {
|
|
||||||
if (typeof (referrer.visit_duration) === 'number') {
|
|
||||||
return durationFormatter(referrer.visit_duration)
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderExternalLink(name) {
|
|
||||||
if (name !== 'Direct / None') {
|
|
||||||
return (
|
|
||||||
<a target="_blank" href={'//' + name} rel="noreferrer" className="hidden group-hover:block">
|
|
||||||
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderReferrerName(referrer) {
|
|
||||||
const filters = replaceFilterByPrefix(this.state.query, "referrer", ["is", "referrer", [referrer.name]])
|
|
||||||
return (
|
return (
|
||||||
<span className="flex group items-center">
|
<img
|
||||||
<img src={`/favicon/sources/${referrer.name}`} referrerPolicy="no-referrer" className="h-4 w-4 mr-2 inline" />
|
src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
|
||||||
<Link
|
className="h-4 w-4 mr-2 align-middle inline"
|
||||||
className="block truncate hover:underline dark:text-gray-200"
|
/>
|
||||||
to={{ search: updatedQuery({ filters }), pathname: '/' + this.props.site.domain }}
|
|
||||||
title={referrer.name}
|
|
||||||
>
|
|
||||||
{referrer.name}
|
|
||||||
</Link>
|
|
||||||
{this.renderExternalLink(referrer.name)}
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
renderReferrer(referrer) {
|
const getExternalLinkURL = useCallback((listItem) => {
|
||||||
return (
|
if (listItem.name !== "Direct / None") {
|
||||||
<tr className="text-sm dark:text-gray-200" key={referrer.name}>
|
return '//' + listItem.name
|
||||||
<td className="p-2">
|
|
||||||
{this.renderReferrerName(referrer)}
|
|
||||||
</td>
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.total_visitors)}</td>}
|
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.visitors)}</td>
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td>}
|
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(referrer)}</td>}
|
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{referrer.conversion_rate}%</td>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
|
||||||
if (this.state.loading) {
|
|
||||||
return (
|
|
||||||
<div className="loading mt-32 mx-auto"><div></div></div>
|
|
||||||
)
|
|
||||||
} else if (this.state.referrers) {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">Referrer drilldown</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
|
||||||
<main className="modal__content mt-0">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed mt-4">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Referrer</th>
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
|
|
||||||
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visit duration</th>}
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{this.state.referrers.map(this.renderReferrer.bind(this))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
return (
|
<Modal site={site}>
|
||||||
<Modal>
|
<BreakdownModal
|
||||||
{this.renderBody()}
|
site={site}
|
||||||
</Modal>
|
query={query}
|
||||||
)
|
reportInfo={reportInfo}
|
||||||
}
|
metrics={chooseMetrics()}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
addSearchFilter={addSearchFilter}
|
||||||
|
renderIcon={renderIcon}
|
||||||
|
getExternalLinkURL={getExternalLinkURL}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(ReferrerDrilldownModal)
|
export default withRouter(withQueryContext(ReferrerDrilldownModal))
|
||||||
|
@ -1,184 +1,96 @@
|
|||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Link, withRouter } from 'react-router-dom'
|
import { withRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import withQueryContext from "../../components/query-context-hoc";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import { hasGoalFilter } from "../../util/filters";
|
||||||
import { parseQuery } from '../../query'
|
import BreakdownModal from "./breakdown-modal";
|
||||||
import { updatedQuery } from "../../util/url";
|
import * as metrics from "../reports/metrics";
|
||||||
import { FILTER_OPERATIONS, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters";
|
import { addFilter } from "../../query";
|
||||||
|
|
||||||
const TITLES = {
|
const VIEWS = {
|
||||||
sources: 'Top Sources',
|
sources: {
|
||||||
utm_mediums: 'Top UTM mediums',
|
info: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'},
|
||||||
utm_sources: 'Top UTM sources',
|
renderIcon: (listItem) => {
|
||||||
utm_campaigns: 'Top UTM campaigns',
|
|
||||||
utm_contents: 'Top UTM contents',
|
|
||||||
utm_terms: 'Top UTM Terms'
|
|
||||||
}
|
|
||||||
|
|
||||||
class SourcesModal extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.state = {
|
|
||||||
loading: true,
|
|
||||||
sources: [],
|
|
||||||
query: parseQuery(props.location.search, props.site),
|
|
||||||
page: 1,
|
|
||||||
moreResultsAvailable: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSources() {
|
|
||||||
const { site } = this.props
|
|
||||||
const { query, page, sources } = this.state
|
|
||||||
|
|
||||||
const detailed = this.showExtra()
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentView()}`, query, { limit: 100, page, detailed })
|
|
||||||
.then((response) => this.setState({ loading: false, sources: sources.concat(response.results), moreResultsAvailable: response.results.length === 100 }))
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.loadSources()
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.location.pathname !== prevProps.location.pathname) {
|
|
||||||
this.setState({ sources: [], loading: true }, this.loadSources.bind(this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentView() {
|
|
||||||
const urlparts = this.props.location.pathname.split('/')
|
|
||||||
return urlparts[urlparts.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
filterKey() {
|
|
||||||
const view = this.currentView()
|
|
||||||
if (view === 'sources') return 'source'
|
|
||||||
if (view === 'utm_mediums') return 'utm_medium'
|
|
||||||
if (view === 'utm_sources') return 'utm_source'
|
|
||||||
if (view === 'utm_campaigns') return 'utm_campaign'
|
|
||||||
if (view === 'utm_contents') return 'utm_content'
|
|
||||||
if (view === 'utm_terms') return 'utm_term'
|
|
||||||
}
|
|
||||||
|
|
||||||
showExtra() {
|
|
||||||
return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
showConversionRate() {
|
|
||||||
return hasGoalFilter(this.state.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMore() {
|
|
||||||
this.setState({ loading: true, page: this.state.page + 1 }, this.loadSources.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBounceRate(page) {
|
|
||||||
if (typeof (page.bounce_rate) === 'number') {
|
|
||||||
return page.bounce_rate + '%'
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatDuration(source) {
|
|
||||||
if (typeof (source.visit_duration) === 'number') {
|
|
||||||
return durationFormatter(source.visit_duration)
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
icon(source) {
|
|
||||||
if (this.currentView() === 'sources') {
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={`/favicon/sources/${encodeURIComponent(source.name)}`}
|
src={`/favicon/sources/${encodeURIComponent(listItem.name)}`}
|
||||||
className="h-4 w-4 mr-2 align-middle inline"
|
className="h-4 w-4 mr-2 align-middle inline"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
utm_mediums: {
|
||||||
renderSource(source) {
|
info: {title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium'}
|
||||||
const filters = replaceFilterByPrefix(this.state.query, this.filterKey(), [FILTER_OPERATIONS.is, this.filterKey(), [source.name]])
|
},
|
||||||
|
utm_sources: {
|
||||||
return (
|
info: {title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source'}
|
||||||
<tr className="text-sm dark:text-gray-200" key={source.name}>
|
},
|
||||||
<td className="p-2">
|
utm_campaigns: {
|
||||||
{this.icon(source)}
|
info: {title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign'}
|
||||||
<Link className="hover:underline" to={{ search: updatedQuery({ filters }), pathname: '/' + encodeURIComponent(this.props.site.domain) }}>{source.name}</Link>
|
},
|
||||||
</td>
|
utm_contents: {
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.total_visitors)}</td>}
|
info: {title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content'}
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.visitors)}</td>
|
},
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(source)}</td>}
|
utm_terms: {
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(source)}</td>}
|
info: {title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term'}
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{source.conversion_rate}%</td>}
|
},
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
label() {
|
|
||||||
if (this.state.query.period === 'realtime') {
|
|
||||||
return 'Current visitors'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.showConversionRate()) {
|
|
||||||
return 'Conversions'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Visitors'
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLoading() {
|
|
||||||
if (this.state.loading) {
|
|
||||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
|
||||||
} else if (this.state.moreResultsAvailable) {
|
|
||||||
return (
|
|
||||||
<div className="w-full text-center my-4">
|
|
||||||
<button onClick={this.loadMore.bind(this)} type="button" className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150">
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title() {
|
|
||||||
return TITLES[this.currentView()]
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Modal>
|
|
||||||
<h1 className="text-xl font-bold dark:text-gray-100">{this.title()}</h1>
|
|
||||||
|
|
||||||
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
|
||||||
|
|
||||||
<main className="modal__content">
|
|
||||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Source</th>
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
|
|
||||||
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{this.label()}</th>
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
|
|
||||||
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visit duration</th>}
|
|
||||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{this.state.sources.map(this.renderSource.bind(this))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{this.renderLoading()}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(SourcesModal)
|
function SourcesModal(props) {
|
||||||
|
const { site, query, location } = props
|
||||||
|
|
||||||
|
const urlParts = location.pathname.split('/')
|
||||||
|
const currentView = urlParts[urlParts.length - 1]
|
||||||
|
|
||||||
|
const reportInfo = VIEWS[currentView].info
|
||||||
|
|
||||||
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
return {
|
||||||
|
prefix: reportInfo.dimension,
|
||||||
|
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addSearchFilter = useCallback((query, searchString) => {
|
||||||
|
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
if (hasGoalFilter(query)) {
|
||||||
|
return [
|
||||||
|
metrics.createTotalVisitors(),
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
||||||
|
metrics.createConversionRate()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.period === 'realtime') {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
|
||||||
|
metrics.createBounceRate(),
|
||||||
|
metrics.createVisitDuration()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal site={site}>
|
||||||
|
<BreakdownModal
|
||||||
|
site={site}
|
||||||
|
query={query}
|
||||||
|
reportInfo={reportInfo}
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
addSearchFilter={addSearchFilter}
|
||||||
|
renderIcon={VIEWS[currentView].renderIcon}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(withQueryContext(SourcesModal))
|
||||||
|
@ -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 url from '../../util/url'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import ListReport from './../reports/list'
|
import ListReport from './../reports/list'
|
||||||
import { VISITORS_METRIC, maybeWithCR } from './../reports/metrics';
|
import * as metrics from './../reports/metrics'
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
||||||
|
import { hasGoalFilter } from '../../util/filters';
|
||||||
|
|
||||||
function EntryPages({ query, site, afterFetchData }) {
|
function EntryPages({ query, site, afterFetchData }) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
@ -23,13 +24,20 @@ function EntryPages({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({defaultLabel: 'Unique Entrances', meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Entry page"
|
keyLabel="Entry page"
|
||||||
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: 'Unique Entrances' }], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath('entry-pages')}
|
detailsLink={url.sitePath('entry-pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
@ -54,13 +62,20 @@ function ExitPages({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({defaultLabel: 'Unique Exits', meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Exit page"
|
keyLabel="Exit page"
|
||||||
metrics={maybeWithCR([{ ...VISITORS_METRIC, label: "Unique Exits" }], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath('exit-pages')}
|
detailsLink={url.sitePath('exit-pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
@ -85,13 +100,20 @@ function TopPages({ query, site, afterFetchData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={afterFetchData}
|
afterFetchData={afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Page"
|
keyLabel="Page"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath('pages')}
|
detailsLink={url.sitePath('pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
|
@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import FlipMove from 'react-flip-move';
|
import FlipMove from 'react-flip-move';
|
||||||
|
|
||||||
import { displayMetricValue, metricLabelFor } from './metrics';
|
|
||||||
import FadeIn from '../../fade-in'
|
import FadeIn from '../../fade-in'
|
||||||
import MoreLink from '../more-link'
|
import MoreLink from '../more-link'
|
||||||
import Bar from '../bar'
|
import Bar from '../bar'
|
||||||
@ -17,18 +16,20 @@ const ROW_GAP_HEIGHT = 4
|
|||||||
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
|
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
|
||||||
const COL_MIN_WIDTH = 70
|
const COL_MIN_WIDTH = 70
|
||||||
|
|
||||||
function FilterLink({ filterQuery, onClick, children }) {
|
export function FilterLink({ pathname, query, filterInfo, onClick, children, extraClass }) {
|
||||||
const className = classNames('max-w-max w-full flex items-center md:overflow-hidden', {
|
const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo })
|
||||||
'hover:underline': !!filterQuery
|
|
||||||
})
|
if (filterInfo) {
|
||||||
|
const {prefix, filter, labels} = filterInfo
|
||||||
|
const newFilters = replaceFilterByPrefix(query, prefix, filter)
|
||||||
|
const newLabels = cleanLabels(newFilters, query.labels, filter[1], labels)
|
||||||
|
const filterQuery = updatedQuery({ filters: newFilters, labels: newLabels })
|
||||||
|
|
||||||
|
let linkTo = { search: filterQuery.toString() }
|
||||||
|
if (pathname) { linkTo.pathname = pathname }
|
||||||
|
|
||||||
if (filterQuery) {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={linkTo} onClick={onClick} className={className}>
|
||||||
to={{ search: filterQuery.toString() }}
|
|
||||||
onClick={onClick}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@ -62,12 +63,12 @@ function ExternalLink({ item, externalLinkDest }) {
|
|||||||
// to be rendered, and should return a list of objects under a `results` key. Think of
|
// to be rendered, and should return a list of objects under a `results` key. Think of
|
||||||
// these objects as rows. The number of columns that are **actually rendered** is also
|
// these objects as rows. The number of columns that are **actually rendered** is also
|
||||||
// configurable through the `metrics` prop, which also defines the keys under which
|
// configurable through the `metrics` prop, which also defines the keys under which
|
||||||
// column values are read. For example:
|
// column values are read, and how they're rendered. For example:
|
||||||
|
|
||||||
// | keyLabel | METRIC_1.label | METRIC_2.label | ...
|
// | keyLabel | METRIC_1.renderLabel(query) | METRIC_1.renderLabel(query) | ...
|
||||||
// |--------------------|---------------------------|---------------------------|-----
|
// |--------------------|-----------------------------|-----------------------------| ---
|
||||||
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ...
|
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ...
|
||||||
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ...
|
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ...
|
||||||
|
|
||||||
// Further configuration of the report is possible through optional props.
|
// Further configuration of the report is possible through optional props.
|
||||||
|
|
||||||
@ -80,9 +81,10 @@ function ExternalLink({ item, externalLinkDest }) {
|
|||||||
// * `fetchData` - a function that returns an `api.get` promise that will resolve to an
|
// * `fetchData` - a function that returns an `api.get` promise that will resolve to an
|
||||||
// object containing a `results` key.
|
// object containing a `results` key.
|
||||||
|
|
||||||
// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at
|
// * `metrics` - a list `Metric` class objects, containing at least the `key,`
|
||||||
// least the `name` and the `label` keys. If the metric should have a different label
|
// `renderLabel`, and `renderValue` fields. Optionally, a Metric object can contain
|
||||||
// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`.
|
// the keys `meta.plot` and `meta.hiddenOnMobile` to represent additional behaviour
|
||||||
|
// for this metric in the ListReport.
|
||||||
|
|
||||||
// * `getFilterFor` - a function that takes a list item and returns [prefix, filter, labels]
|
// * `getFilterFor` - a function that takes a list item and returns [prefix, filter, labels]
|
||||||
// that should be applied when the list item is clicked. All existing filters matching prefix
|
// that should be applied when the list item is clicked. All existing filters matching prefix
|
||||||
@ -164,12 +166,12 @@ export default function ListReport(props) {
|
|||||||
// we want to display are actually there in the API response.
|
// we want to display are actually there in the API response.
|
||||||
function getAvailableMetrics() {
|
function getAvailableMetrics() {
|
||||||
return metrics.filter((metric) => {
|
return metrics.filter((metric) => {
|
||||||
return state.list.some((listItem) => listItem[metric.name] != null)
|
return state.list.some((listItem) => listItem[metric.key] != null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function hiddenOnMobileClass(metric) {
|
function hiddenOnMobileClass(metric) {
|
||||||
if (metric.hiddenOnMobile) {
|
if (metric.meta.hiddenOnMobile) {
|
||||||
return 'hidden md:block'
|
return 'hidden md:block'
|
||||||
} else {
|
} else {
|
||||||
return ''
|
return ''
|
||||||
@ -199,11 +201,11 @@ export default function ListReport(props) {
|
|||||||
const metricLabels = getAvailableMetrics().map((metric) => {
|
const metricLabels = getAvailableMetrics().map((metric) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={metric.name}
|
key={metric.key}
|
||||||
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
||||||
style={{ minWidth: colMinWidth }}
|
style={{ minWidth: colMinWidth }}
|
||||||
>
|
>
|
||||||
{metricLabelFor(metric, props.query)}
|
{ metric.renderLabel(props.query) }
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -235,21 +237,10 @@ export default function ListReport(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterQuery(listItem) {
|
|
||||||
const prefixAndFilter = props.getFilterFor(listItem)
|
|
||||||
if (!prefixAndFilter) { return null }
|
|
||||||
|
|
||||||
const {prefix, filter, labels} = prefixAndFilter
|
|
||||||
const newFilters = replaceFilterByPrefix(props.query, prefix, filter)
|
|
||||||
const newLabels = cleanLabels(newFilters, props.query.labels, filter[1], labels)
|
|
||||||
|
|
||||||
return updatedQuery({ filters: newFilters, labels: newLabels })
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBarFor(listItem) {
|
function renderBarFor(listItem) {
|
||||||
const lightBackground = props.color || 'bg-green-50'
|
const lightBackground = props.color || 'bg-green-50'
|
||||||
const noop = () => { }
|
const noop = () => { }
|
||||||
const metricToPlot = metrics.find(m => m.plot).name
|
const metricToPlot = metrics.find(metric => metric.meta.plot).key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-grow w-full overflow-hidden">
|
<div className="flex-grow w-full overflow-hidden">
|
||||||
@ -260,7 +251,12 @@ export default function ListReport(props) {
|
|||||||
plot={metricToPlot}
|
plot={metricToPlot}
|
||||||
>
|
>
|
||||||
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
||||||
<FilterLink filterQuery={getFilterQuery(listItem)} onClick={props.onClick || noop}>
|
<FilterLink
|
||||||
|
query={props.query}
|
||||||
|
filterInfo={props.getFilterFor(listItem)}
|
||||||
|
onClick={props.onClick || noop}
|
||||||
|
extraClass="max-w-max w-full flex items-center md:overflow-hidden"
|
||||||
|
>
|
||||||
{maybeRenderIconFor(listItem)}
|
{maybeRenderIconFor(listItem)}
|
||||||
|
|
||||||
<span className="w-full md:truncate">
|
<span className="w-full md:truncate">
|
||||||
@ -284,12 +280,12 @@ export default function ListReport(props) {
|
|||||||
return getAvailableMetrics().map((metric) => {
|
return getAvailableMetrics().map((metric) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${listItem.name}__${metric.name}`}
|
key={`${listItem.name}__${metric.key}`}
|
||||||
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
||||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
||||||
{displayMetricValue(listItem[metric.name], metric)}
|
{ metric.renderValue(listItem[metric.key]) }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { hasGoalFilter } from "../../util/filters"
|
import { hasGoalFilter } from "../../util/filters"
|
||||||
import numberFormatter from "../../util/number-formatter"
|
import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
@ -14,42 +14,151 @@ function maybeRequire() {
|
|||||||
|
|
||||||
const Money = maybeRequire().default
|
const Money = maybeRequire().default
|
||||||
|
|
||||||
export const VISITORS_METRIC = {
|
// Class representation of a metric.
|
||||||
name: 'visitors',
|
|
||||||
label: 'Visitors',
|
|
||||||
realtimeLabel: 'Current visitors',
|
|
||||||
goalFilterLabel: 'Conversions',
|
|
||||||
plot: true
|
|
||||||
}
|
|
||||||
export const PERCENTAGE_METRIC = { name: 'percentage', label: '%' }
|
|
||||||
export const CR_METRIC = { name: 'conversion_rate', label: 'CR' }
|
|
||||||
|
|
||||||
export function maybeWithCR(metrics, query) {
|
// Metric instances can be created directly via the Metric constructor,
|
||||||
if (metrics.includes(PERCENTAGE_METRIC) && hasGoalFilter(query)) {
|
// or using special creator functions like `createVisitors`, which just
|
||||||
return metrics.filter((m) => { return m !== PERCENTAGE_METRIC }).concat([CR_METRIC])
|
// fill out the known fields for that metric.
|
||||||
}
|
|
||||||
else if (hasGoalFilter(query)) {
|
// ### Required props
|
||||||
return metrics.concat(CR_METRIC)
|
|
||||||
}
|
// * `key` - the key under which to read values under in an API
|
||||||
else {
|
|
||||||
return metrics
|
// * `renderValue` - a function that takes a value of this metric, and
|
||||||
|
// and returns the "rendered" version of it. Can be JSX or a string.
|
||||||
|
|
||||||
|
// * `renderLabel` - a function rendering a label for this metric given a
|
||||||
|
// query argument. Can return JSX or string.
|
||||||
|
|
||||||
|
// ### Optional props
|
||||||
|
|
||||||
|
// * `meta` - a map with extra context for this metric. E.g. `plot`, or
|
||||||
|
// `hiddenOnMobile` define some special behaviours in the context where
|
||||||
|
// it's used.
|
||||||
|
export class Metric {
|
||||||
|
constructor(props) {
|
||||||
|
if (!props.key) {
|
||||||
|
throw Error("Required field `key` is missing")
|
||||||
|
}
|
||||||
|
if (typeof props.renderLabel !== 'function') {
|
||||||
|
throw Error("Required field `renderLabel` should be a function")
|
||||||
|
}
|
||||||
|
if (typeof props.renderValue !== 'function') {
|
||||||
|
throw Error("Required field `renderValue` should be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.key = props.key
|
||||||
|
this.renderValue = props.renderValue
|
||||||
|
this.renderLabel = props.renderLabel
|
||||||
|
this.meta = props.meta || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayMetricValue(value, metric) {
|
// Creates a Metric class representing the `visitors` metric.
|
||||||
if (['total_revenue', 'average_revenue'].includes(metric.name)) {
|
|
||||||
return <Money formatted={value} />
|
// Optional props for conveniently generating the `renderLabel` function:
|
||||||
} else if (metric === PERCENTAGE_METRIC) {
|
|
||||||
return value
|
// * `defaultLabel` - label when not realtime, and no goal filter applied
|
||||||
} else if (metric === CR_METRIC) {
|
// * `realtimeLabel` - label when realtime period
|
||||||
return `${value}%`
|
// * `goalFilterLabel` - label when goal filter is applied
|
||||||
|
export const createVisitors = (props) => {
|
||||||
|
let renderValue
|
||||||
|
|
||||||
|
if (typeof props.renderValue === 'function') {
|
||||||
|
renderValue = props.renderValue
|
||||||
} else {
|
} else {
|
||||||
return <span tooltip={value}>{numberFormatter(value)}</span>
|
renderValue = renderNumberWithTooltip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let renderLabel
|
||||||
|
|
||||||
|
if (typeof props.renderLabel === 'function') {
|
||||||
|
renderLabel = props.renderLabel
|
||||||
|
} else {
|
||||||
|
renderLabel = (query) => {
|
||||||
|
const defaultLabel = props.defaultLabel || 'Visitors'
|
||||||
|
const realtimeLabel = props.realtimeLabel || 'Current visitors'
|
||||||
|
const goalFilterLabel = props.goalFilterLabel || 'Conversions'
|
||||||
|
|
||||||
|
if (query.period === 'realtime') { return realtimeLabel }
|
||||||
|
if (query && hasGoalFilter(query)) { return goalFilterLabel }
|
||||||
|
return defaultLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Metric({...props, key: "visitors", renderValue, renderLabel})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function metricLabelFor(metric, query) {
|
export const createConversionRate = (props) => {
|
||||||
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel }
|
const renderValue = percentageFormatter
|
||||||
if (metric.goalFilterLabel && hasGoalFilter(query)) { return metric.goalFilterLabel }
|
const renderLabel = (_query) => "CR"
|
||||||
return metric.label
|
return new Metric({...props, key: "conversion_rate", renderLabel, renderValue})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createPercentage = (props) => {
|
||||||
|
const renderValue = (value) => value
|
||||||
|
const renderLabel = (_query) => "%"
|
||||||
|
return new Metric({...props, key: "percentage", renderLabel, renderValue})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEvents = (props) => {
|
||||||
|
const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip
|
||||||
|
return new Metric({...props, key: "events", renderValue: renderValue})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTotalRevenue = (props) => {
|
||||||
|
const renderValue = (value) => <Money formatted={value} />
|
||||||
|
const renderLabel = (_query) => "Revenue"
|
||||||
|
return new Metric({...props, key: "total_revenue", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAverageRevenue = (props) => {
|
||||||
|
const renderValue = (value) => <Money formatted={value} />
|
||||||
|
const renderLabel = (_query) => "Average"
|
||||||
|
return new Metric({...props, key: "average_revenue", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTotalVisitors = (props) => {
|
||||||
|
const renderValue = renderNumberWithTooltip
|
||||||
|
const renderLabel = (_query) => "Total Visitors"
|
||||||
|
return new Metric({...props, key: "total_visitors", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVisits = (props) => {
|
||||||
|
const renderValue = renderNumberWithTooltip
|
||||||
|
return new Metric({...props, key: "visits", renderValue})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVisitDuration = (props) => {
|
||||||
|
const renderValue = durationFormatter
|
||||||
|
const renderLabel = (_query) => "Visit Duration"
|
||||||
|
return new Metric({...props, key: "visit_duration", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBounceRate = (props) => {
|
||||||
|
const renderValue = (value) => `${value}%`
|
||||||
|
const renderLabel = (_query) => "Bounce Rate"
|
||||||
|
return new Metric({...props, key: "bounce_rate", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPageviews = (props) => {
|
||||||
|
const renderValue = renderNumberWithTooltip
|
||||||
|
const renderLabel = (_query) => "Pageviews"
|
||||||
|
return new Metric({...props, key: "pageviews", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTimeOnPage = (props) => {
|
||||||
|
const renderValue = durationFormatter
|
||||||
|
const renderLabel = (_query) => "Time on Page"
|
||||||
|
return new Metric({...props, key: "time_on_page", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createExitRate = (props) => {
|
||||||
|
const renderValue = percentageFormatter
|
||||||
|
const renderLabel = (_query) => "Exit Rate"
|
||||||
|
return new Metric({...props, key: "exit_rate", renderValue, renderLabel})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNumberWithTooltip(value) {
|
||||||
|
return <span tooltip={value}>{numberFormatter(value)}</span>
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics'
|
import * as metrics from '../reports/metrics'
|
||||||
|
import { hasGoalFilter } from "../../util/filters"
|
||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning'
|
import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning'
|
||||||
|
|
||||||
@ -44,6 +45,13 @@ export default function Referrers({ source, site, query }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex flex-col flex-grow">
|
||||||
<div className="flex gap-x-1">
|
<div className="flex gap-x-1">
|
||||||
@ -55,7 +63,7 @@ export default function Referrers({ source, site, query }) {
|
|||||||
afterFetchData={afterFetchReferrers}
|
afterFetchData={afterFetchReferrers}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Referrer"
|
keyLabel="Referrer"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)}
|
detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
|
@ -4,7 +4,8 @@ import * as storage from '../../util/storage'
|
|||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics';
|
import * as metrics from '../reports/metrics';
|
||||||
|
import { hasGoalFilter } from "../../util/filters"
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
@ -41,13 +42,20 @@ function AllSources(props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={props.afterFetchData}
|
afterFetchData={props.afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Source"
|
keyLabel="Source"
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath('sources')}
|
detailsLink={url.sitePath('sources')}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
query={query}
|
query={query}
|
||||||
@ -71,13 +79,20 @@ function UTMSources(props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({meta: {plot: true}}),
|
||||||
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
|
].filter(metric => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
afterFetchData={props.afterFetchData}
|
afterFetchData={props.afterFetchData}
|
||||||
getFilterFor={getFilterFor}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel={utmTag.label}
|
keyLabel={utmTag.label}
|
||||||
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
metrics={chooseMetrics()}
|
||||||
detailsLink={url.sitePath(utmTag.endpoint)}
|
detailsLink={url.sitePath(utmTag.endpoint)}
|
||||||
query={query}
|
query={query}
|
||||||
color="bg-blue-50"
|
color="bg-blue-50"
|
||||||
|
@ -37,26 +37,26 @@ defmodule PlausibleWeb.AdminControllerTest do
|
|||||||
} do
|
} do
|
||||||
patch_env(:super_admin_user_ids, [user.id])
|
patch_env(:super_admin_user_ids, [user.id])
|
||||||
|
|
||||||
s1 = insert(:site)
|
s1 = insert(:site, inserted_at: ~N[2024-01-01 00:00:00])
|
||||||
insert_list(3, :site_membership, site: s1)
|
insert_list(3, :site_membership, site: s1)
|
||||||
s2 = insert(:site)
|
s2 = insert(:site, inserted_at: ~N[2024-01-02 00:00:00])
|
||||||
insert_list(3, :site_membership, site: s2)
|
insert_list(3, :site_membership, site: s2)
|
||||||
s3 = insert(:site)
|
s3 = insert(:site, inserted_at: ~N[2024-01-03 00:00:00])
|
||||||
insert_list(3, :site_membership, site: s3)
|
insert_list(3, :site_membership, site: s3)
|
||||||
|
|
||||||
conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"})
|
conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"})
|
||||||
page1_html = html_response(conn1, 200)
|
page1_html = html_response(conn1, 200)
|
||||||
|
|
||||||
assert page1_html =~ s1.domain
|
assert page1_html =~ s3.domain
|
||||||
assert page1_html =~ s2.domain
|
assert page1_html =~ s2.domain
|
||||||
refute page1_html =~ s3.domain
|
refute page1_html =~ s1.domain
|
||||||
|
|
||||||
conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"})
|
conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"})
|
||||||
page2_html = html_response(conn2, 200)
|
page2_html = html_response(conn2, 200)
|
||||||
|
|
||||||
refute page2_html =~ s1.domain
|
refute page2_html =~ s3.domain
|
||||||
refute page2_html =~ s2.domain
|
refute page2_html =~ s2.domain
|
||||||
assert page2_html =~ s3.domain
|
assert page2_html =~ s1.domain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -2385,10 +2385,11 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
assert json_response(conn, 200)["results"] == [
|
results = json_response(conn, 200)["results"]
|
||||||
%{"dimensions" => ["/plausible.io"], "metrics" => [100]},
|
|
||||||
%{"dimensions" => ["/important-page"], "metrics" => [100]}
|
assert length(results) == 2
|
||||||
]
|
assert %{"dimensions" => ["/plausible.io"], "metrics" => [100]} in results
|
||||||
|
assert %{"dimensions" => ["/important-page"], "metrics" => [100]} in results
|
||||||
end
|
end
|
||||||
|
|
||||||
test "IN filter for event:name", %{conn: conn, site: site} do
|
test "IN filter for event:name", %{conn: conn, site: site} do
|
||||||
|
Loading…
Reference in New Issue
Block a user