From 7d0321fd222a15deb228cb7e7a08254ac92ec9fa Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:01:52 +0300 Subject: [PATCH] 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 --- .../dashboard/components/query-context-hoc.js | 50 +++ assets/js/dashboard/custom-hooks.js | 14 +- assets/js/dashboard/index.js | 39 +-- assets/js/dashboard/query.js | 25 ++ assets/js/dashboard/realtime.js | 2 +- assets/js/dashboard/router.js | 25 +- .../dashboard/stats/behaviours/conversions.js | 20 +- .../stats/behaviours/goal-conversions.js | 16 +- assets/js/dashboard/stats/behaviours/props.js | 23 +- assets/js/dashboard/stats/devices/index.js | 54 +++- assets/js/dashboard/stats/graph/graph-util.js | 30 +- .../js/dashboard/stats/graph/visitor-graph.js | 9 +- assets/js/dashboard/stats/locations/index.js | 30 +- .../dashboard/stats/modals/breakdown-modal.js | 306 ++++++++++++++++++ .../js/dashboard/stats/modals/conversions.js | 158 +++------ .../js/dashboard/stats/modals/entry-pages.js | 201 ++++-------- .../js/dashboard/stats/modals/exit-pages.js | 178 ++++------ .../dashboard/stats/modals/locations-modal.js | 73 +++++ assets/js/dashboard/stats/modals/pages.js | 194 ++++------- assets/js/dashboard/stats/modals/props.js | 162 +++------- .../stats/modals/referrer-drilldown.js | 196 ++++------- assets/js/dashboard/stats/modals/sources.js | 258 +++++---------- assets/js/dashboard/stats/modals/table.js | 120 ------- assets/js/dashboard/stats/pages/index.js | 30 +- assets/js/dashboard/stats/reports/list.js | 74 ++--- assets/js/dashboard/stats/reports/metrics.js | 171 ++++++++-- .../dashboard/stats/sources/referrer-list.js | 12 +- .../js/dashboard/stats/sources/source-list.js | 21 +- .../controllers/admin_controller_test.exs | 14 +- .../external_stats_controller/query_test.exs | 9 +- 30 files changed, 1262 insertions(+), 1252 deletions(-) create mode 100644 assets/js/dashboard/components/query-context-hoc.js create mode 100644 assets/js/dashboard/stats/modals/breakdown-modal.js create mode 100644 assets/js/dashboard/stats/modals/locations-modal.js delete mode 100644 assets/js/dashboard/stats/modals/table.js diff --git a/assets/js/dashboard/components/query-context-hoc.js b/assets/js/dashboard/components/query-context-hoc.js new file mode 100644 index 000000000..3dd65be80 --- /dev/null +++ b/assets/js/dashboard/components/query-context-hoc.js @@ -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 ( + + ) + } +} \ No newline at end of file diff --git a/assets/js/dashboard/custom-hooks.js b/assets/js/dashboard/custom-hooks.js index adb955f15..4bbe92bdc 100644 --- a/assets/js/dashboard/custom-hooks.js +++ b/assets/js/dashboard/custom-hooks.js @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; // A custom hook that behaves like `useEffect`, but // the function does not run on the initial render. @@ -12,4 +12,16 @@ export function useMountedEffect(fn, deps) { mounted.current = true } }, deps) +} + +// A custom hook that debounces the function calls by +// a given delay. Cancels all function calls that have +// a following call within `delay_ms`. +export function useDebouncedEffect(fn, deps, delay_ms) { + const callback = useCallback(fn, deps) + + useEffect(() => { + const timeout = setTimeout(callback, delay_ms) + return () => clearTimeout(timeout) + }, [callback, delay_ms]) } \ No newline at end of file diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index ca755c344..158003b88 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -1,35 +1,22 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react' import { withRouter } from 'react-router-dom' -import { useMountedEffect } from './custom-hooks'; import Historical from './historical' import Realtime from './realtime' -import {parseQuery} from './query' -import * as api from './api' +import withQueryContext from './components/query-context-hoc'; export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded" function Dashboard(props) { - const { location, site, loggedIn, currentUserRole } = props - const [query, setQuery] = useState(parseQuery(location.search, site)) - const [importedDataInView, setImportedDataInView] = useState(false) - const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) - const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } - - useEffect(() => { - document.addEventListener('tick', updateLastLoadTimestamp) - - return () => { - document.removeEventListener('tick', updateLastLoadTimestamp) - } - }, []) - - useMountedEffect(() => { - api.cancelAll() - setQuery(parseQuery(location.search, site)) - updateLastLoadTimestamp() - }, [location.search]) - + const { + site, + loggedIn, + currentUserRole, + query, + importedDataInView, + updateImportedDataInView, + lastLoadTimestamp + } = props if (query.period === 'realtime') { return ( @@ -50,10 +37,10 @@ function Dashboard(props) { query={query} lastLoadTimestamp={lastLoadTimestamp} importedDataInView={importedDataInView} - updateImportedDataInView={setImportedDataInView} + updateImportedDataInView={updateImportedDataInView} /> ) } } -export default withRouter(Dashboard) +export default withRouter(withQueryContext(Dashboard)) diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index 2e18fd5de..104d70592 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -5,6 +5,7 @@ import { PlausibleSearchParams, updatedQuery } from './util/url' import { nowForSite } from './util/date' import * as storage from './util/storage' import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input' +import { getFiltersByKeyPrefix } from './util/filters' import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; @@ -47,6 +48,10 @@ export function parseQuery(querystring, site) { } } +export function addFilter(query, filter) { + return {...query, filters: [...query.filters, filter]} +} + export function navigateToQuery(history, queryFrom, newData) { // if we update any data that we store in localstorage, make sure going back in history will // revert them @@ -133,6 +138,26 @@ export function filtersBackwardsCompatibilityRedirect() { } } +// Returns a boolean indicating whether the given query includes a +// non-empty goal filterset containing a single, or multiple revenue +// goals with the same currency. Used to decide whether to render +// revenue metrics in a dashboard report or not. +export function revenueAvailable(query, site) { + const revenueGoalsInFilter = site.revenueGoals.filter((rg) => { + const goalFilters = getFiltersByKeyPrefix(query, "goal") + + return goalFilters.some(([_op, _key, clauses]) => { + return clauses.includes(rg.event_name) + }) + }) + + const singleCurrency = revenueGoalsInFilter.every((rg) => { + return rg.currency === revenueGoalsInFilter[0].currency + }) + + return revenueGoalsInFilter.length > 0 && singleCurrency +} + function QueryLink(props) { const { query, history, to, className, children } = props diff --git a/assets/js/dashboard/realtime.js b/assets/js/dashboard/realtime.js index 9eb83e8db..9df8e24ce 100644 --- a/assets/js/dashboard/realtime.js +++ b/assets/js/dashboard/realtime.js @@ -28,7 +28,7 @@ function Realtime(props) { - +
diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js index 8f591f622..742112a30 100644 --- a/assets/js/dashboard/router.js +++ b/assets/js/dashboard/router.js @@ -8,11 +8,10 @@ import GoogleKeywordsModal from './stats/modals/google-keywords' import PagesModal from './stats/modals/pages' import EntryPagesModal from './stats/modals/entry-pages' import ExitPagesModal from './stats/modals/exit-pages' -import ModalTable from './stats/modals/table' +import LocationsModal from './stats/modals/locations-modal'; import PropsModal from './stats/modals/props' import ConversionsModal from './stats/modals/conversions' import FilterModal from './stats/modals/filter-modal' -import * as url from './util/url'; function ScrollToTop() { const location = useLocation(); @@ -51,14 +50,8 @@ export default function Router({ site, loggedIn, currentUserRole }) { - - - - - - - - + + @@ -74,15 +67,3 @@ export default function Router({ site, loggedIn, currentUserRole }) { ); } - -function renderCityIcon(city) { - return {city.country_flag} -} - -function renderCountryIcon(country) { - return {country.flag} -} - -function renderRegionIcon(region) { - return {region.country_flag} -} diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js index 6161b9457..b9a344bf2 100644 --- a/assets/js/dashboard/stats/behaviours/conversions.js +++ b/assets/js/dashboard/stats/behaviours/conversions.js @@ -2,7 +2,7 @@ import React from 'react'; import * as api from '../../api' import * as url from '../../util/url' -import { CR_METRIC } from '../reports/metrics'; +import * as metrics from '../reports/metrics'; import ListReport from '../reports/list'; export default function Conversions(props) { @@ -19,6 +19,16 @@ export default function Conversions(props) { } } + function chooseMetrics() { + return [ + metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: {plot: true}}), + metrics.createEvents({renderLabel: (_query) => "Total", meta: {hiddenOnMobile: true}}), + metrics.createConversionRate(), + BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}), + BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}}) + ].filter(metric => !!metric) + } + /*global BUILD_EXTRA*/ return ( "Visitors", meta: {plot: true}}), + metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}), + metrics.createConversionRate() + ].filter(metric => !!metric) + } + return ( "Visitors", meta: {plot: true}}), + metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + !hasGoalFilter(query) && metrics.createPercentage(), + BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}), + BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}}) + ].filter(metric => !!metric) + } + function renderBreakdown() { return ( !!metric) + } + return ( @@ -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 ( @@ -148,6 +164,14 @@ function OperatingSystems({ query, site, afterFetchData }) { } } + function chooseMetrics() { + return [ + metrics.createVisitors({ meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + !hasGoalFilter(query) && metrics.createPercentage({meta: {hiddenonMobile: true}}) + ].filter(metric => !!metric) + } + function renderIcon(listItem) { return osIconFor(listItem.name) } @@ -159,7 +183,7 @@ function OperatingSystems({ query, site, afterFetchData }) { getFilterFor={getFilterFor} renderIcon={renderIcon} keyLabel="Operating system" - metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)} + metrics={chooseMetrics()} query={query} /> ) @@ -189,6 +213,14 @@ function OperatingSystemVersions({ query, site, afterFetchData }) { } } + function chooseMetrics() { + return [ + metrics.createVisitors({ meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + !hasGoalFilter(query) && metrics.createPercentage() + ].filter(metric => !!metric) + } + return ( ) @@ -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 ( diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index 59b96bf53..4b4cc0be4 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -1,41 +1,27 @@ import numberFormatter, {durationFormatter} from '../../util/number-formatter' -import { getFiltersByKeyPrefix, getGoalFilter } from '../../util/filters' +import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters' +import { revenueAvailable } from '../../query' export function getGraphableMetrics(query, site) { const isRealtime = query.period === 'realtime' - const goalFilter = getGoalFilter(query) - const hasPageFilter = getFiltersByKeyPrefix(query, "page").length > 0 + const isGoalFilter = hasGoalFilter(query) + const isPageFilter = getFiltersByKeyPrefix(query, "page").length > 0 - if (isRealtime && goalFilter) { + if (isRealtime && isGoalFilter) { return ["visitors"] } else if (isRealtime) { return ["visitors", "pageviews"] - } else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) { + } else if (isGoalFilter && revenueAvailable(query, site)) { return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"] - } else if (goalFilter) { + } else if (isGoalFilter) { return ["visitors", "events", "conversion_rate"] - } else if (hasPageFilter) { + } else if (isPageFilter) { return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"] } else { return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"] } } -// Revenue metrics can only be graphed if: -// * The query is filtered by at least one revenue goal -// * All revenue goals in filter have the same currency -function canGraphRevenueMetrics([_operation, _filterKey, clauses], site) { - const revenueGoalsInFilter = site.revenueGoals.filter((rg) => { - return clauses.includes(rg.event_name) - }) - - const singleCurrency = revenueGoalsInFilter.every((rg) => { - return rg.currency === revenueGoalsInFilter[0].currency - }) - - return revenueGoalsInFilter.length > 0 && singleCurrency -} - export const METRIC_LABELS = { 'visitors': 'Visitors', 'pageviews': 'Pageviews', diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index 2b12588f8..cc371fe59 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -136,7 +136,14 @@ export default function VisitorGraph(props) { {(topStatsLoading || graphLoading) && renderLoader()}
- +
{graphRefreshing && renderLoader()} diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 23f4a4bc2..bc5d6bf81 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -6,7 +6,8 @@ import CountriesMap from './map' import * as api from '../../api' import { apiPath, sitePath } from '../../util/url' import ListReport from '../reports/list' -import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics'; +import * as metrics from '../reports/metrics'; +import { hasGoalFilter } from "../../util/filters" import { getFiltersByKeyPrefix } from '../../util/filters'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; @@ -27,6 +28,13 @@ function Countries({ query, site, onClick, afterFetchData }) { } } + function chooseMetrics() { + return [ + metrics.createVisitors({ meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + ].filter(metric => !!metric) + } + return ( !!metric) + } + return ( !!metric) + } + return ( `, 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 ( + + + + ) + } + } + + function renderRow(item) { + return ( + + + { maybeRenderIcon(item) } + + {trimURL(item.name, 40)} + + { maybeRenderExternalLink(item) } + + {metrics.map((metric) => { + return ( + + {metric.renderValue(item[metric.key])} + + ) + })} + + ) + } + + function renderInitialLoadingSpinner() { + return ( +
+
+
+ ) + } + + function renderSmallLoadingSpinner() { + return ( +
+ ) + } + + function renderLoadMoreButton() { + return ( +
+ +
+ ) + } + + function handleInputChange(e) { + setSearchInput(e.target.value) + } + + function renderSearchInput() { + return ( + + ) + } + + function renderModalBody() { + if (results) { + return ( +
+ + + + + + {metrics.map((metric) => { + return ( + + ) + })} + + + + { results.map(renderRow) } + +
+ {reportInfo.dimensionLabel} + + {metric.renderLabel(query)} +
+
+ ) + } + } + + return ( +
+
+
+

{ reportInfo.title }

+ { !initialLoading && loading && renderSmallLoadingSpinner() } +
+ { searchEnabled && renderSearchInput()} +
+
+
+ { initialLoading && renderInitialLoadingSpinner() } + { !initialLoading && renderModalBody() } + { !loading && moreResultsAvailable && renderLoadMoreButton() } +
+
+ ) +} diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 38efbfa7d..112927df6 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -1,128 +1,72 @@ -import React, { useEffect, useState } from "react"; -import { Link } from 'react-router-dom' +import React, { useCallback, useState } from "react"; import { withRouter } from 'react-router-dom' import Modal from './modal' -import * as api from '../../api' -import * as url from "../../util/url"; -import numberFormatter from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { replaceFilterByPrefix } from '../../util/filters' +import withQueryContext from "../../components/query-context-hoc"; +import BreakdownModal from "./breakdown-modal"; +import * as metrics from "../reports/metrics"; + /*global BUILD_EXTRA*/ -/*global require*/ -function maybeRequire() { - if (BUILD_EXTRA) { - return require('../../extra/money') - } else { - return { default: null } - } -} - -const Money = maybeRequire().default - function ConversionsModal(props) { - const site = props.site - const query = parseQuery(props.location.search, site) + const { site, query } = props + const [showRevenue, setShowRevenue] = useState(false) - const [loading, setLoading] = useState(true) - const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - const [page, setPage] = useState(1) - const [list, setList] = useState([]) + const reportInfo = { + title: 'Goal Conversions', + dimension: 'goal', + endpoint: '/conversions', + dimensionLabel: "Goal" + } - useEffect(() => { - fetchData() + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] + } }, []) - function fetchData() { - api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page }) - .then((response) => { - setLoading(false) - setList(list.concat(response.results)) - setPage(page + 1) - setMoreResultsAvailable(response.results.length >= 100) - }) + function chooseMetrics() { + return [ + metrics.createVisitors({renderLabel: (_query) => "Uniques"}), + metrics.createEvents({renderLabel: (_query) => "Total"}), + metrics.createConversionRate(), + showRevenue && metrics.createAverageRevenue(), + showRevenue && metrics.createTotalRevenue(), + ].filter(metric => !!metric) } - function loadMore() { - setLoading(true) - fetchData() - } + // After a successful API response, we want to scan the rows of the + // response and update the internal `showRevenue` state, which decides + // whether revenue metrics are passed into BreakdownModal in `metrics`. + const afterFetchData = useCallback((res) => { + setShowRevenue(revenueInResponse(res)) + }, [showRevenue]) - function renderLoadMore() { - return ( -
- -
- ) - } + // After fetching the next page, we never want to set `showRevenue` to + // `false` as revenue metrics might exist in previously loaded data. + const afterFetchNextPage = useCallback((res) => { + if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) } + }, [showRevenue]) - function filterSearchLink(listItem) { - const filters = replaceFilterByPrefix(query, "goal", ["is", "goal", [listItem.name]]) - return url.updatedQuery({ filters }) - } - - function renderListItem(listItem, hasRevenue) { - return ( - - - - {listItem.name} - - - {numberFormatter(listItem.visitors)} - {numberFormatter(listItem.events)} - {listItem.conversion_rate}% - {hasRevenue && } - {hasRevenue && } - - ) - } - - function renderLoading() { - return
- } - - function renderBody() { - const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue) - - return ( - <> -

Goal Conversions

- -
-
- - - - - - - - {hasRevenue && } - {hasRevenue && } - - - - {list.map((item) => renderListItem(item, hasRevenue))} - -
GoalUniquesTotalCRRevenueAverage
-
- - ) + function revenueInResponse(apiResponse) { + return apiResponse.results.some((item) => item.total_revenue) } return ( - - {renderBody()} - {loading && renderLoading()} - {!loading && moreResultsAvailable && renderLoadMore()} + + ) } -export default withRouter(ConversionsModal) +export default withRouter(withQueryContext(ConversionsModal)) diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 04007c6fd..03171ee8f 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -1,158 +1,67 @@ -import React from "react"; -import { Link, withRouter } from 'react-router-dom' - - +import React, {useCallback} from "react"; +import { withRouter } from 'react-router-dom' import Modal from './modal' -import * as api from '../../api' -import numberFormatter, { durationFormatter } from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { trimURL, updatedQuery } from '../../util/url' -import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; +import { hasGoalFilter } from "../../util/filters"; +import { addFilter } from '../../query' +import BreakdownModal from "./breakdown-modal"; +import * as metrics from '../reports/metrics' +import withQueryContext from "../../components/query-context-hoc"; -class EntryPagesModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site), - pages: [], - page: 1, - moreResultsAvailable: false +function EntryPagesModal(props) { + const { site, query } = props + + const reportInfo = { + title: 'Entry Pages', + dimension: 'entry_page', + endpoint: '/entry-pages', + dimensionLabel: 'Entry page' + } + + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - } + }, []) - componentDidMount() { - this.loadPages(); - } + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) + }, []) - loadPages() { - const { query, page } = this.state; - - api.get( - `/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`, - query, - { limit: 100, page } - ) - .then( - (response) => this.setState((state) => ({ - loading: false, - pages: state.pages.concat(response.results), - moreResultsAvailable: response.results.length === 100 - })) - ) - } - - loadMore() { - const { page } = this.state; - this.setState({ loading: true, page: page + 1 }, this.loadPages.bind(this)) - } - - formatBounceRate(page) { - if (typeof (page.bounce_rate) === 'number') { - return `${page.bounce_rate}%`; - } - return '-'; - } - - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - showExtra() { - return this.state.query.period !== 'realtime' && !this.showConversionRate() - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - if (this.showConversionRate()) { - return 'Conversions' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } - - return 'Visitors' + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createVisits({renderLabel: (_query) => "Total Entrances" }), + metrics.createVisitDuration() + ] } - renderPage(page) { - const filters = replaceFilterByPrefix(this.state.query, "entry_page", ["is", "entry_page", [page.name]]) - return ( - - - - {trimURL(page.name, 40)} - - - {this.showConversionRate() && {numberFormatter(page.total_visitors)}} - {numberFormatter(page.visitors)} - {this.showExtra() && {numberFormatter(page.visits)}} - {this.showExtra() && {durationFormatter(page.visit_duration)}} - {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%} - - ) - } - - renderLoading() { - if (this.state.loading) { - return
- } else if (this.state.moreResultsAvailable) { - return ( -
- -
- ) - } - } - - renderBody() { - if (this.state.pages) { - return ( - <> -

Entry Pages

- -
-
- - - - - {this.showConversionRate() && } - - {this.showExtra() && } - {this.showExtra() && } - {this.showConversionRate() && } - - - - {this.state.pages.map(this.renderPage.bind(this))} - -
Page url - Total Visitors {this.label()} Total Entrances Visit Duration CR
-
- - ) - } - } - - render() { - return ( - - {this.renderBody()} - {this.renderLoading()} - - ) - } + return ( + + + + ) } -export default withRouter(EntryPagesModal) +export default withRouter(withQueryContext(EntryPagesModal)) diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 25be36b3a..55ea1405e 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -1,135 +1,67 @@ -import React from "react"; -import { Link } from 'react-router-dom' +import React, {useCallback} from "react"; import { withRouter } from 'react-router-dom' - import Modal from './modal' -import * as api from '../../api' -import numberFormatter, { percentageFormatter } from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { trimURL, updatedQuery } from '../../util/url' -import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; -class ExitPagesModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site), - pages: [], - page: 1, - moreResultsAvailable: false +import { hasGoalFilter } from "../../util/filters"; +import { addFilter } from '../../query' +import BreakdownModal from "./breakdown-modal"; +import * as metrics from '../reports/metrics' +import withQueryContext from "../../components/query-context-hoc"; + +function ExitPagesModal(props) { + const { site, query } = props + + const reportInfo = { + title: 'Exit Pages', + dimension: 'exit_page', + endpoint: '/exit-pages', + dimensionLabel: 'Page url' + } + + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - } + }, []) - componentDidMount() { - this.loadPages(); - } + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) + }, []) - loadPages() { - const { query, page } = this.state; - - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/exit-pages`, query, { limit: 100, page }) - .then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 }))) - } - - loadMore() { - this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this)) - } - - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - showExtra() { - return this.state.query.period !== 'realtime' && !this.showConversionRate() - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - if (this.showConversionRate()) { - return 'Conversions' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } - - return 'Visitors' + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createVisits({renderLabel: (_query) => "Total Exits" }), + metrics.createExitRate() + ] } - renderPage(page) { - const filters = replaceFilterByPrefix(this.state.query, "exit_page", ["is", "exit_page", [page.name]]) - return ( - - - - {trimURL(page.name, 40)} - - - {this.showConversionRate() && {numberFormatter(page.total_visitors)}} - {numberFormatter(page.visitors)} - {this.showExtra() && {numberFormatter(page.visits)}} - {this.showExtra() && {percentageFormatter(page.exit_rate)}} - {this.showConversionRate() && {numberFormatter(page.conversion_rate)}%} - - ) - } - - renderLoading() { - if (this.state.loading) { - return
- } else if (this.state.moreResultsAvailable) { - return ( -
- -
- ) - } - } - - renderBody() { - if (this.state.pages) { - return ( - -

Exit Pages

- -
-
- - - - - {this.showConversionRate() && } - - {this.showExtra() && } - {this.showExtra() && } - {this.showConversionRate() && } - - - - {this.state.pages.map(this.renderPage.bind(this))} - -
Page urlTotal Visitors {this.label()}Total ExitsExit RateCR
-
-
- ) - } - } - - render() { - return ( - - {this.renderBody()} - {this.renderLoading()} - - ) - } + return ( + + + + ) } -export default withRouter(ExitPagesModal) +export default withRouter(withQueryContext(ExitPagesModal)) diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js new file mode 100644 index 000000000..c8bab2a66 --- /dev/null +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -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 ( + {listItem.country_flag || listItem.flag} + ) + }, []) + + return ( + + + + ) +} + +export default withRouter(withQueryContext(LocationsModal)) diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index c1819163f..739b38de9 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -1,152 +1,68 @@ -import React from "react"; -import { Link } from 'react-router-dom' +import React, {useCallback} from "react"; import { withRouter } from 'react-router-dom' - import Modal from './modal' -import * as api from '../../api' -import numberFormatter, { durationFormatter } from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { trimURL, updatedQuery } from '../../util/url' -import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; +import { hasGoalFilter } from "../../util/filters"; +import { addFilter } from '../../query' +import BreakdownModal from "./breakdown-modal"; +import * as metrics from '../reports/metrics' +import withQueryContext from "../../components/query-context-hoc"; -class PagesModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site), - pages: [], - page: 1, - moreResultsAvailable: false +function PagesModal(props) { + const { site, query } = props + + const reportInfo = { + title: 'Top Pages', + dimension: 'page', + endpoint: '/pages', + dimensionLabel: 'Page url' + } + + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - } + }, []) - componentDidMount() { - this.loadPages(); - } + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) + }, []) - loadPages() { - const detailed = this.showExtra() - const { query, page } = this.state; - - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, { limit: 100, page, detailed }) - .then((response) => this.setState((state) => ({ loading: false, pages: state.pages.concat(response.results), moreResultsAvailable: response.results.length === 100 }))) - } - - loadMore() { - this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this)) - } - - showExtra() { - return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query) - } - - showPageviews() { - return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query) - } - - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - formatBounceRate(page) { - if (typeof (page.bounce_rate) === 'number') { - return page.bounce_rate + '%' - } else { - return '-' - } - } - - renderPage(page) { - const filters = replaceFilterByPrefix(this.state.query, "page", ["is", "page", [page.name]]) - const timeOnPage = page['time_on_page'] ? durationFormatter(page['time_on_page']) : '-'; - return ( - - - - {trimURL(page.name, 50)} - - - {this.showConversionRate() && {page.total_visitors}} - {numberFormatter(page.visitors)} - {this.showPageviews() && {numberFormatter(page.pageviews)}} - {this.showExtra() && {this.formatBounceRate(page)}} - {this.showExtra() && {timeOnPage}} - {this.showConversionRate() && {page.conversion_rate}%} - - ) - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - if (this.showConversionRate()) { - return 'Conversions' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } - - return 'Visitors' + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createPageviews(), + metrics.createBounceRate(), + metrics.createTimeOnPage() + ] } - renderLoading() { - if (this.state.loading) { - return
- } else if (this.state.moreResultsAvailable) { - return ( -
- -
- ) - } - } - - renderBody() { - if (this.state.pages) { - return ( - -

Top Pages

- -
-
- - - - - {this.showConversionRate() && } - - {this.showPageviews() && } - {this.showExtra() && } - {this.showExtra() && } - {this.showConversionRate() && } - - - - {this.state.pages.map(this.renderPage.bind(this))} - -
Page urlTotal visitors{this.label()}PageviewsBounce rateTime on PageCR
-
-
- ) - } - } - - render() { - return ( - - {this.renderBody()} - {this.renderLoading()} - - ) - } + return ( + + + + ) } -export default withRouter(PagesModal) +export default withRouter(withQueryContext(PagesModal)) diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 64fe66a97..d77359d81 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -1,137 +1,63 @@ -import React, { useEffect, useState } from "react"; -import { Link } from 'react-router-dom' +import React, { useCallback } from "react"; import { withRouter } from 'react-router-dom' import Modal from './modal' -import * as api from '../../api' -import * as url from "../../util/url"; -import numberFormatter from '../../util/number-formatter' -import { parseQuery } from '../../query' +import withQueryContext from "../../components/query-context-hoc"; +import { addFilter } from '../../query' import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; -import { EVENT_PROPS_PREFIX, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters" - -/*global BUILD_EXTRA*/ -/*global require*/ -function maybeRequire() { - if (BUILD_EXTRA) { - return require('../../extra/money') - } else { - return { default: null } - } -} - -const Money = maybeRequire().default +import { EVENT_PROPS_PREFIX, hasGoalFilter } from "../../util/filters" +import BreakdownModal from "./breakdown-modal"; +import * as metrics from "../reports/metrics"; +import { revenueAvailable } from "../../query"; function PropsModal(props) { - const site = props.site - const query = parseQuery(props.location.search, site) + const {site, query, location} = props + const propKey = location.pathname.split('/').filter(i => i).pop() - const propKey = props.location.pathname.split('/').filter(i => i).pop() + /*global BUILD_EXTRA*/ + const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) - const [loading, setLoading] = useState(true) - const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - const [page, setPage] = useState(1) - const [list, setList] = useState([]) + const reportInfo = { + title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), + dimension: propKey, + endpoint: `/custom-prop-values/${propKey}`, + dimensionLabel: propKey + } - useEffect(() => { - fetchData() + const getFilterInfo = useCallback((listItem) => { + return { + prefix: `${EVENT_PROPS_PREFIX}${propKey}`, + filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] + } }, []) - function fetchData() { - api.get(url.apiPath(site, `/custom-prop-values/${propKey}`), query, { limit: 100, page }) - .then((response) => { - setLoading(false) - setList(list.concat(response.results)) - setPage(page + 1) - setMoreResultsAvailable(response.results.length >= 100) - }) - } + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]]) + }, []) - function loadMore() { - setLoading(true) - fetchData() - } - - function renderLoadMore() { - return ( -
- -
- ) - } - - 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 ( - - - - {url.trimURL(listItem.name, 30)} - - - {numberFormatter(listItem.visitors)} - {numberFormatter(listItem.events)} - { - hasGoalFilter(query) ? ( - {listItem.conversion_rate}% - ) : ( - {listItem.percentage} - ) - } - {hasRevenue && } - {hasRevenue && } - - ) - } - - function renderLoading() { - return
- } - - function renderBody() { - const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue) - - return ( - <> -

{specialTitleWhenGoalFilter(query, 'Custom Property Breakdown')}

- -
-
- - - - - - - - {hasRevenue && } - {hasRevenue && } - - - - {list.map((item) => renderListItem(item, hasRevenue))} - -
{propKey}VisitorsEvents{hasGoalFilter(query) ? 'CR' : '%'}RevenueAverage
-
- - ) + function chooseMetrics() { + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors"}), + metrics.createEvents({renderLabel: (_query) => "Events"}), + hasGoalFilter(query) && metrics.createConversionRate(), + !hasGoalFilter(query) && metrics.createPercentage(), + showRevenueMetrics && metrics.createAverageRevenue(), + showRevenueMetrics && metrics.createTotalRevenue(), + ].filter(metric => !!metric) } return ( - - {renderBody()} - {loading && renderLoading()} - {!loading && moreResultsAvailable && renderLoadMore()} + + ) } -export default withRouter(PropsModal) +export default withRouter(withQueryContext(PropsModal)) diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index ff1e4caf5..04b4a61b4 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -1,147 +1,85 @@ -import React from "react"; -import { Link, withRouter } from 'react-router-dom' +import React, { useCallback } from "react"; +import { withRouter } from 'react-router-dom' import Modal from './modal' -import * as api from '../../api' -import numberFormatter, { durationFormatter } from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { updatedQuery } from "../../util/url"; -import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; +import withQueryContext from "../../components/query-context-hoc"; +import { hasGoalFilter } from "../../util/filters"; +import BreakdownModal from "./breakdown-modal"; +import * as metrics from "../reports/metrics"; +import { addFilter } from "../../query"; -class ReferrerDrilldownModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site) +function ReferrerDrilldownModal(props) { + const { site, query, match } = props + + const reportInfo = { + title: "Referrer Drilldown", + dimension: 'referrer', + endpoint: `/referrers/${match.params.referrer}`, + dimensionLabel: "Referrer" + } + + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ['is', reportInfo.dimension, [listItem.name]] } - } + }, []) - componentDidMount() { - const detailed = this.showExtra() + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) + }, []) - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.match.params.referrer}`, this.state.query, { limit: 100, detailed }) - .then((response) => this.setState({ loading: false, referrers: response.results })) - } - - showExtra() { - return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query) - } - - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - if (this.showConversionRate()) { - return 'Conversions' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } - - return 'Visitors' + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createBounceRate(), + metrics.createVisitDuration() + ] } - formatBounceRate(ref) { - if (typeof (ref.bounce_rate) === 'number') { - return ref.bounce_rate + '%' - } else { - return '-' - } - } - - formatDuration(referrer) { - if (typeof (referrer.visit_duration) === 'number') { - return durationFormatter(referrer.visit_duration) - } else { - return '-' - } - } - - renderExternalLink(name) { - if (name !== 'Direct / None') { - return ( - - - - ) - } - } - - renderReferrerName(referrer) { - const filters = replaceFilterByPrefix(this.state.query, "referrer", ["is", "referrer", [referrer.name]]) + const renderIcon = useCallback((listItem) => { return ( - - - - {referrer.name} - - {this.renderExternalLink(referrer.name)} - + ) - } + }, []) - renderReferrer(referrer) { - return ( - - - {this.renderReferrerName(referrer)} - - {this.showConversionRate() && {numberFormatter(referrer.total_visitors)}} - {numberFormatter(referrer.visitors)} - {this.showExtra() && {this.formatBounceRate(referrer)}} - {this.showExtra() && {this.formatDuration(referrer)}} - {this.showConversionRate() && {referrer.conversion_rate}%} - - ) - } - - renderBody() { - if (this.state.loading) { - return ( -
- ) - } else if (this.state.referrers) { - return ( - -

Referrer drilldown

- -
-
- - - - - {this.showConversionRate() && } - - {this.showExtra() && } - {this.showExtra() && } - {this.showConversionRate() && } - - - - {this.state.referrers.map(this.renderReferrer.bind(this))} - -
ReferrerTotal visitors{this.label()}Bounce rateVisit durationCR
-
-
- ) + const getExternalLinkURL = useCallback((listItem) => { + if (listItem.name !== "Direct / None") { + return '//' + listItem.name } - } + }, []) - render() { - return ( - - {this.renderBody()} - - ) - } + return ( + + + + ) } -export default withRouter(ReferrerDrilldownModal) +export default withRouter(withQueryContext(ReferrerDrilldownModal)) diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 8e9efa068..c5a31d61e 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -1,184 +1,96 @@ -import React from "react"; -import { Link, withRouter } from 'react-router-dom' +import React, { useCallback } from "react"; +import { withRouter } from 'react-router-dom' import Modal from './modal' -import * as api from '../../api' -import numberFormatter, { durationFormatter } from '../../util/number-formatter' -import { parseQuery } from '../../query' -import { updatedQuery } from "../../util/url"; -import { FILTER_OPERATIONS, hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; +import withQueryContext from "../../components/query-context-hoc"; +import { hasGoalFilter } from "../../util/filters"; +import BreakdownModal from "./breakdown-modal"; +import * as metrics from "../reports/metrics"; +import { addFilter } from "../../query"; -const TITLES = { - sources: 'Top Sources', - utm_mediums: 'Top UTM mediums', - utm_sources: 'Top UTM sources', - utm_campaigns: 'Top UTM campaigns', - utm_contents: 'Top UTM contents', - utm_terms: 'Top UTM Terms' -} - -class SourcesModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - sources: [], - query: parseQuery(props.location.search, props.site), - page: 1, - moreResultsAvailable: false - } - } - - loadSources() { - const { site } = this.props - const { query, page, sources } = this.state - - const detailed = this.showExtra() - api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentView()}`, query, { limit: 100, page, detailed }) - .then((response) => this.setState({ loading: false, sources: sources.concat(response.results), moreResultsAvailable: response.results.length === 100 })) - } - - componentDidMount() { - this.loadSources() - } - - componentDidUpdate(prevProps) { - if (this.props.location.pathname !== prevProps.location.pathname) { - this.setState({ sources: [], loading: true }, this.loadSources.bind(this)) - } - } - - currentView() { - const urlparts = this.props.location.pathname.split('/') - return urlparts[urlparts.length - 1] - } - - filterKey() { - const view = this.currentView() - if (view === 'sources') return 'source' - if (view === 'utm_mediums') return 'utm_medium' - if (view === 'utm_sources') return 'utm_source' - if (view === 'utm_campaigns') return 'utm_campaign' - if (view === 'utm_contents') return 'utm_content' - if (view === 'utm_terms') return 'utm_term' - } - - showExtra() { - return this.state.query.period !== 'realtime' && !hasGoalFilter(this.state.query) - } - - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - loadMore() { - this.setState({ loading: true, page: this.state.page + 1 }, this.loadSources.bind(this)) - } - - formatBounceRate(page) { - if (typeof (page.bounce_rate) === 'number') { - return page.bounce_rate + '%' - } else { - return '-' - } - } - - formatDuration(source) { - if (typeof (source.visit_duration) === 'number') { - return durationFormatter(source.visit_duration) - } else { - return '-' - } - } - - icon(source) { - if (this.currentView() === 'sources') { +const VIEWS = { + sources: { + info: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'}, + renderIcon: (listItem) => { return ( ) } - } - - renderSource(source) { - const filters = replaceFilterByPrefix(this.state.query, this.filterKey(), [FILTER_OPERATIONS.is, this.filterKey(), [source.name]]) - - return ( - - - {this.icon(source)} - {source.name} - - {this.showConversionRate() && {numberFormatter(source.total_visitors)}} - {numberFormatter(source.visitors)} - {this.showExtra() && {this.formatBounceRate(source)}} - {this.showExtra() && {this.formatDuration(source)}} - {this.showConversionRate() && {source.conversion_rate}%} - - ) - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' - } - - if (this.showConversionRate()) { - return 'Conversions' - } - - return 'Visitors' - } - - renderLoading() { - if (this.state.loading) { - return
- } else if (this.state.moreResultsAvailable) { - return ( -
- -
- ) - } - } - - title() { - return TITLES[this.currentView()] - } - - render() { - return ( - -

{this.title()}

- -
- -
- - - - - {this.showConversionRate() && } - - {this.showExtra() && } - {this.showExtra() && } - {this.showConversionRate() && } - - - - {this.state.sources.map(this.renderSource.bind(this))} - -
SourceTotal visitors{this.label()}Bounce rateVisit durationCR
-
- - {this.renderLoading()} -
- ) - } + }, + utm_mediums: { + info: {title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium'} + }, + utm_sources: { + info: {title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source'} + }, + utm_campaigns: { + info: {title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign'} + }, + utm_contents: { + info: {title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content'} + }, + utm_terms: { + info: {title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term'} + }, } -export default withRouter(SourcesModal) +function SourcesModal(props) { + const { site, query, location } = props + + const urlParts = location.pathname.split('/') + const currentView = urlParts[urlParts.length - 1] + + const reportInfo = VIEWS[currentView].info + + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] + } + }, []) + + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) + }, []) + + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] + } + + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] + } + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createBounceRate(), + metrics.createVisitDuration() + ] + } + + return ( + + + + ) +} + +export default withRouter(withQueryContext(SourcesModal)) diff --git a/assets/js/dashboard/stats/modals/table.js b/assets/js/dashboard/stats/modals/table.js deleted file mode 100644 index fc4861776..000000000 --- a/assets/js/dashboard/stats/modals/table.js +++ /dev/null @@ -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 ( - - - - {this.props.renderIcon && this.props.renderIcon(tableItem)} - {this.props.renderIcon && ' '} - {tableItem.name} - - - {this.showConversionRate() && {numberFormatter(tableItem.total_visitors)}} - {numberFormatter(tableItem.visitors)} - {this.showPercentage() && {tableItem.percentage}} - {this.showConversionRate() && {numberFormatter(tableItem.conversion_rate)}%} - - ) - } - - renderBody() { - if (this.state.loading) { - return ( -
- ) - } - - if (this.state.list) { - return ( - <> -

{this.props.title}

- -
-
- - - - - {this.showConversionRate() && } - - {this.showPercentage() && } - {this.showConversionRate() && } - - - - {this.state.list.map(this.renderTableItem.bind(this))} - -
{this.props.keyLabel}Total Visitors{this.label()}%CR
-
- - ) - } - - return null - } - - render() { - return ( - - {this.renderBody()} - - ) - } -} - -export default withRouter(ModalTable) diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index ee76a8bdc..6f2e3329c 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -4,8 +4,9 @@ import * as storage from '../../util/storage' import * as url from '../../util/url' import * as api from '../../api' import ListReport from './../reports/list' -import { VISITORS_METRIC, maybeWithCR } from './../reports/metrics'; +import * as metrics from './../reports/metrics' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; +import { hasGoalFilter } from '../../util/filters'; function EntryPages({ query, site, afterFetchData }) { function fetchData() { @@ -23,13 +24,20 @@ function EntryPages({ query, site, afterFetchData }) { } } + function chooseMetrics() { + return [ + metrics.createVisitors({defaultLabel: 'Unique Entrances', meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + ].filter(metric => !!metric) + } + return ( !!metric) + } + return ( !!metric) + } + return ( + {children} ) @@ -62,12 +63,12 @@ function ExternalLink({ item, externalLinkDest }) { // to be rendered, and should return a list of objects under a `results` key. Think of // these objects as rows. The number of columns that are **actually rendered** is also // configurable through the `metrics` prop, which also defines the keys under which -// column values are read. For example: +// column values are read, and how they're rendered. For example: -// | keyLabel | METRIC_1.label | METRIC_2.label | ... -// |--------------------|---------------------------|---------------------------|----- -// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ... -// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ... +// | keyLabel | METRIC_1.renderLabel(query) | METRIC_1.renderLabel(query) | ... +// |--------------------|-----------------------------|-----------------------------| --- +// | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... +// | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... // Further configuration of the report is possible through optional props. @@ -80,9 +81,10 @@ function ExternalLink({ item, externalLinkDest }) { // * `fetchData` - a function that returns an `api.get` promise that will resolve to an // object containing a `results` key. -// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at -// least the `name` and the `label` keys. If the metric should have a different label -// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`. +// * `metrics` - a list `Metric` class objects, containing at least the `key,` +// `renderLabel`, and `renderValue` fields. Optionally, a Metric object can contain +// the keys `meta.plot` and `meta.hiddenOnMobile` to represent additional behaviour +// for this metric in the ListReport. // * `getFilterFor` - a function that takes a list item and returns [prefix, filter, labels] // that should be applied when the list item is clicked. All existing filters matching prefix @@ -164,12 +166,12 @@ export default function ListReport(props) { // we want to display are actually there in the API response. function getAvailableMetrics() { return metrics.filter((metric) => { - return state.list.some((listItem) => listItem[metric.name] != null) + return state.list.some((listItem) => listItem[metric.key] != null) }) } function hiddenOnMobileClass(metric) { - if (metric.hiddenOnMobile) { + if (metric.meta.hiddenOnMobile) { return 'hidden md:block' } else { return '' @@ -199,11 +201,11 @@ export default function ListReport(props) { const metricLabels = getAvailableMetrics().map((metric) => { return (
- {metricLabelFor(metric, props.query)} + { metric.renderLabel(props.query) }
) }) @@ -235,21 +237,10 @@ export default function ListReport(props) { ) } - function getFilterQuery(listItem) { - const prefixAndFilter = props.getFilterFor(listItem) - if (!prefixAndFilter) { return null } - - const {prefix, filter, labels} = prefixAndFilter - const newFilters = replaceFilterByPrefix(props.query, prefix, filter) - const newLabels = cleanLabels(newFilters, props.query.labels, filter[1], labels) - - return updatedQuery({ filters: newFilters, labels: newLabels }) - } - function renderBarFor(listItem) { const lightBackground = props.color || 'bg-green-50' const noop = () => { } - const metricToPlot = metrics.find(m => m.plot).name + const metricToPlot = metrics.find(metric => metric.meta.plot).key return (
@@ -260,7 +251,12 @@ export default function ListReport(props) { plot={metricToPlot} >
- + {maybeRenderIconFor(listItem)} @@ -284,12 +280,12 @@ export default function ListReport(props) { return getAvailableMetrics().map((metric) => { return (
- {displayMetricValue(listItem[metric.name], metric)} + { metric.renderValue(listItem[metric.key]) }
) diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 110332319..db5704e66 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -1,5 +1,5 @@ import { hasGoalFilter } from "../../util/filters" -import numberFormatter from "../../util/number-formatter" +import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter" import React from "react" /*global BUILD_EXTRA*/ @@ -14,42 +14,151 @@ function maybeRequire() { const Money = maybeRequire().default -export const VISITORS_METRIC = { - name: 'visitors', - label: 'Visitors', - realtimeLabel: 'Current visitors', - goalFilterLabel: 'Conversions', - plot: true -} -export const PERCENTAGE_METRIC = { name: 'percentage', label: '%' } -export const CR_METRIC = { name: 'conversion_rate', label: 'CR' } +// Class representation of a metric. -export function maybeWithCR(metrics, query) { - if (metrics.includes(PERCENTAGE_METRIC) && hasGoalFilter(query)) { - return metrics.filter((m) => { return m !== PERCENTAGE_METRIC }).concat([CR_METRIC]) - } - else if (hasGoalFilter(query)) { - return metrics.concat(CR_METRIC) - } - else { - return metrics +// Metric instances can be created directly via the Metric constructor, +// or using special creator functions like `createVisitors`, which just +// fill out the known fields for that metric. + +// ### Required props + +// * `key` - the key under which to read values under in an API + +// * `renderValue` - a function that takes a value of this metric, and +// and returns the "rendered" version of it. Can be JSX or a string. + +// * `renderLabel` - a function rendering a label for this metric given a +// query argument. Can return JSX or string. + +// ### Optional props + +// * `meta` - a map with extra context for this metric. E.g. `plot`, or +// `hiddenOnMobile` define some special behaviours in the context where +// it's used. +export class Metric { + constructor(props) { + if (!props.key) { + throw Error("Required field `key` is missing") + } + if (typeof props.renderLabel !== 'function') { + throw Error("Required field `renderLabel` should be a function") + } + if (typeof props.renderValue !== 'function') { + throw Error("Required field `renderValue` should be a function") + } + + this.key = props.key + this.renderValue = props.renderValue + this.renderLabel = props.renderLabel + this.meta = props.meta || {} } } -export function displayMetricValue(value, metric) { - if (['total_revenue', 'average_revenue'].includes(metric.name)) { - return - } else if (metric === PERCENTAGE_METRIC) { - return value - } else if (metric === CR_METRIC) { - return `${value}%` +// Creates a Metric class representing the `visitors` metric. + +// Optional props for conveniently generating the `renderLabel` function: + +// * `defaultLabel` - label when not realtime, and no goal filter applied +// * `realtimeLabel` - label when realtime period +// * `goalFilterLabel` - label when goal filter is applied +export const createVisitors = (props) => { + let renderValue + + if (typeof props.renderValue === 'function') { + renderValue = props.renderValue } else { - return {numberFormatter(value)} + renderValue = renderNumberWithTooltip } + + let renderLabel + + if (typeof props.renderLabel === 'function') { + renderLabel = props.renderLabel + } else { + renderLabel = (query) => { + const defaultLabel = props.defaultLabel || 'Visitors' + const realtimeLabel = props.realtimeLabel || 'Current visitors' + const goalFilterLabel = props.goalFilterLabel || 'Conversions' + + if (query.period === 'realtime') { return realtimeLabel } + if (query && hasGoalFilter(query)) { return goalFilterLabel } + return defaultLabel + } + } + + return new Metric({...props, key: "visitors", renderValue, renderLabel}) } -export function metricLabelFor(metric, query) { - if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel } - if (metric.goalFilterLabel && hasGoalFilter(query)) { return metric.goalFilterLabel } - return metric.label +export const createConversionRate = (props) => { + const renderValue = percentageFormatter + const renderLabel = (_query) => "CR" + return new Metric({...props, key: "conversion_rate", renderLabel, renderValue}) } + +export const createPercentage = (props) => { + const renderValue = (value) => value + const renderLabel = (_query) => "%" + return new Metric({...props, key: "percentage", renderLabel, renderValue}) +} + +export const createEvents = (props) => { + const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip + return new Metric({...props, key: "events", renderValue: renderValue}) +} + +export const createTotalRevenue = (props) => { + const renderValue = (value) => + const renderLabel = (_query) => "Revenue" + return new Metric({...props, key: "total_revenue", renderValue, renderLabel}) +} + +export const createAverageRevenue = (props) => { + const renderValue = (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 {numberFormatter(value)} +} \ No newline at end of file diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index 3ebc1af63..06c9c375d 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import * as api from '../../api' import * as url from '../../util/url' -import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics' +import * as metrics from '../reports/metrics' +import { hasGoalFilter } from "../../util/filters" import ListReport from '../reports/list' import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning' @@ -44,6 +45,13 @@ export default function Referrers({ source, site, query }) { ) } + function chooseMetrics() { + return [ + metrics.createVisitors({meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + ].filter(metric => !!metric) + } + return (
@@ -55,7 +63,7 @@ export default function Referrers({ source, site, query }) { afterFetchData={afterFetchReferrers} getFilterFor={getFilterFor} keyLabel="Referrer" - metrics={maybeWithCR([VISITORS_METRIC], query)} + metrics={chooseMetrics()} detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)} query={query} externalLinkDest={externalLinkDest} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index 806dfc819..adfe5645d 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -4,7 +4,8 @@ import * as storage from '../../util/storage' import * as url from '../../util/url' import * as api from '../../api' import ListReport from '../reports/list' -import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics'; +import * as metrics from '../reports/metrics'; +import { hasGoalFilter } from "../../util/filters" import { Menu, Transition } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' @@ -41,13 +42,20 @@ function AllSources(props) { ) } + function chooseMetrics() { + return [ + metrics.createVisitors({meta: {plot: true}}), + hasGoalFilter(query) && metrics.createConversionRate(), + ].filter(metric => !!metric) + } + return ( !!metric) + } + return ( "2"}) page1_html = html_response(conn1, 200) - assert page1_html =~ s1.domain + assert page1_html =~ s3.domain assert page1_html =~ s2.domain - refute page1_html =~ s3.domain + refute page1_html =~ s1.domain conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"}) page2_html = html_response(conn2, 200) - refute page2_html =~ s1.domain + refute page2_html =~ s3.domain refute page2_html =~ s2.domain - assert page2_html =~ s3.domain + assert page2_html =~ s1.domain end end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs index 8bcd82a1a..5af0f7043 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/query_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs @@ -2385,10 +2385,11 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do ] }) - assert json_response(conn, 200)["results"] == [ - %{"dimensions" => ["/plausible.io"], "metrics" => [100]}, - %{"dimensions" => ["/important-page"], "metrics" => [100]} - ] + results = json_response(conn, 200)["results"] + + assert length(results) == 2 + assert %{"dimensions" => ["/plausible.io"], "metrics" => [100]} in results + assert %{"dimensions" => ["/important-page"], "metrics" => [100]} in results end test "IN filter for event:name", %{conn: conn, site: site} do