From be9a185ef3ab91a3a589b4be2158982672ed2901 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 2 Jul 2024 15:25:49 +0300 Subject: [PATCH 01/32] Create a new BreakdownModal component and use it for Entry Pages --- .../dashboard/stats/modals/breakdown-modal.js | 128 ++++++++++++ .../js/dashboard/stats/modals/entry-pages.js | 185 ++++-------------- 2 files changed, 168 insertions(+), 145 deletions(-) create mode 100644 assets/js/dashboard/stats/modals/breakdown-modal.js diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js new file mode 100644 index 000000000000..28dc775b1458 --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from "react"; +import { Link } from 'react-router-dom' + +import * as api from '../../api' +import { trimURL, updatedQuery } from '../../util/url' +import { replaceFilterByPrefix } from "../../util/filters"; + +const LIMIT = 100 + +export default function BreakdownModal(props) { + const {site, query, reportInfo, getMetrics} = props + const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` + const metrics = getMetrics(query) + + const [loading, setLoading] = useState(true) + const [results, setResults] = useState([]) + const [page, setPage] = useState(1) + const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) + + useEffect(fetchData, [page]) + + function fetchData() { + api.get(endpoint, query, { limit: LIMIT, page }) + .then((response) => { + setLoading(false) + setResults(results.concat(response.results)) + setMoreResultsAvailable(response.results.length === LIMIT) + }) + } + + function loadNextPage() { + setLoading(true) + setPage(page + 1) + } + + function renderRow(item) { + const filters = replaceFilterByPrefix(query, reportInfo.dimension, ["is", reportInfo.dimension, [item.name]]) + + return ( + + + + {trimURL(item.name, 40)} + + + {metrics.map((metric) => { + return ( + + {metric.formatter(item[metric.key])} + + ) + })} + + ) + } + + function renderLoading() { + if (loading) { + return
+ } else if (moreResultsAvailable) { + return ( +
+ +
+ ) + } + } + + function renderBody() { + if (results) { + return ( + <> +

{ reportInfo.title }

+ +
+
+ + + + + + {metrics.map((metric) => { + return ( + + ) + })} + + + + { results.map(renderRow) } + +
+ {reportInfo.dimensionLabel} + + {metric.label} +
+
+ + ) + } + } + + return ( +
+ { renderBody() } + { renderLoading() } +
+ ) +} diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 04007c6fd68c..2a881c643e0d 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -1,158 +1,53 @@ -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 { hasGoalFilter } from "../../util/filters"; import { parseQuery } from '../../query' -import { trimURL, updatedQuery } from '../../util/url' -import { hasGoalFilter, replaceFilterByPrefix } from "../../util/filters"; - -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 - } - } - - componentDidMount() { - this.loadPages(); - } +import BreakdownModal from "./breakdown-modal"; - loadPages() { - const { query, page } = this.state; +function EntryPagesModal(props) { + const query = parseQuery(props.location.search, props.site) - 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 - })) - ) + const reportInfo = { + title: 'Entry Pages', + dimension: 'entry_page', + endpoint: '/entry-pages', + dimensionLabel: 'Entry page' } - 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}%`; + const getMetrics = useCallback((query) => { + if (hasGoalFilter(query)) { + return [ + {key: 'total_visitors', label: 'Total visitors', formatter: numberFormatter}, + {key: 'visitors', label: 'Conversions', formatter: numberFormatter}, + {key: 'conversion_rate', label: 'CR', formatter: numberFormatter} + ] } - 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 (query.period === 'realtime') { + return [ + {key: 'visitors', label: "Current visitors", formatter: numberFormatter} + ] } - - if (this.showConversionRate()) { - return 'Conversions' - } - - return 'Visitors' - } - - 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 [ + {key: 'visitors', label: "Visitors", formatter: numberFormatter}, + {key: 'visits', label: "Total Entrances", formatter: numberFormatter}, + {key: 'visit_duration', label: "Visit Duration", formatter: durationFormatter} + ] + }, []) + + return ( + + + + ) } export default withRouter(EntryPagesModal) From d0094a761d0868dec4e6a9d31c9ab94358b5c906 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 2 Jul 2024 18:21:49 +0300 Subject: [PATCH 02/32] Add search functionality into the new component --- assets/js/dashboard/query.js | 4 ++ .../dashboard/stats/modals/breakdown-modal.js | 44 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index 4a61841e9873..eef98f7519ce 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -48,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 diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 28dc775b1458..b9afb298f9cb 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,7 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Link } from 'react-router-dom' import * as api from '../../api' +import { addFilter } from '../../query' +import debounce from 'debounce-promise' +import { useMountedEffect } from '../../custom-hooks' import { trimURL, updatedQuery } from '../../util/url' import { replaceFilterByPrefix } from "../../util/filters"; @@ -13,19 +16,38 @@ export default function BreakdownModal(props) { const metrics = getMetrics(query) const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') const [results, setResults] = useState([]) const [page, setPage] = useState(1) const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - useEffect(fetchData, [page]) - - function fetchData() { - api.get(endpoint, query, { limit: LIMIT, page }) + const fetchData = useCallback(debounce(() => { + api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1 }) .then((response) => { setLoading(false) - setResults(results.concat(response.results)) + setPage(1) + setResults(response.results) setMoreResultsAvailable(response.results.length === LIMIT) }) + }, 200), [search]) + + useEffect(() => { fetchData() }, [search]) + useMountedEffect(() => { fetchNextPage() }, [page]) + + function fetchNextPage() { + if (page > 1) { + api.get(endpoint, withSearch(query), { limit: LIMIT, page }) + .then((response) => { + setLoading(false) + setResults(results.concat(response.results)) + setMoreResultsAvailable(response.results.length === LIMIT) + }) + } + } + + function withSearch(query) { + if (search === '') { return query} + return addFilter(query, ['contains', reportInfo.dimension, [search]]) } function loadNextPage() { @@ -82,7 +104,15 @@ export default function BreakdownModal(props) { if (results) { return ( <> -

{ reportInfo.title }

+
+

{ reportInfo.title }

+ { setSearch(e.target.value) }} + /> +
From caac24468968d543de36e67b9beae2a08bf9c9d3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 2 Jul 2024 19:49:55 +0300 Subject: [PATCH 03/32] Adjust FilterLink component and use it in BreakdownModal --- .../dashboard/stats/modals/breakdown-modal.js | 19 ++++----- .../js/dashboard/stats/modals/entry-pages.js | 8 ++++ assets/js/dashboard/stats/reports/list.js | 40 +++++++++---------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index b9afb298f9cb..f60a339caa7e 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,12 +1,11 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Link } from 'react-router-dom' import * as api from '../../api' import { addFilter } from '../../query' import debounce from 'debounce-promise' import { useMountedEffect } from '../../custom-hooks' -import { trimURL, updatedQuery } from '../../util/url' -import { replaceFilterByPrefix } from "../../util/filters"; +import { trimURL } from '../../util/url' +import { FilterLink } from "../reports/list"; const LIMIT = 100 @@ -56,20 +55,16 @@ export default function BreakdownModal(props) { } function renderRow(item) { - const filters = replaceFilterByPrefix(query, reportInfo.dimension, ["is", reportInfo.dimension, [item.name]]) - return ( - {trimURL(item.name, 40)} - + {metrics.map((metric) => { return ( diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 2a881c643e0d..7a707c2a8f4e 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -16,6 +16,13 @@ function EntryPagesModal(props) { dimensionLabel: 'Entry page' } + const getFilterInfo = (listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] + } + } + const getMetrics = useCallback((query) => { if (hasGoalFilter(query)) { return [ @@ -45,6 +52,7 @@ function EntryPagesModal(props) { query={query} reportInfo={reportInfo} getMetrics={getMetrics} + getFilterInfo={getFilterInfo} /> ) diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index ed869ccf82af..66fa07d8727b 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -17,18 +17,20 @@ const ROW_GAP_HEIGHT = 4 const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT const COL_MIN_WIDTH = 70 -function FilterLink({ filterQuery, onClick, children }) { - const className = classNames('max-w-max w-full flex items-center md:overflow-hidden', { - 'hover:underline': !!filterQuery - }) +export function FilterLink({ pathname, query, filterInfo, onClick, children, extraClass }) { + const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo }) + + if (filterInfo) { + const {prefix, filter, labels} = filterInfo + const newFilters = replaceFilterByPrefix(query, prefix, filter) + const newLabels = cleanLabels(newFilters, query.labels, filter[1], labels) + const filterQuery = updatedQuery({ filters: newFilters, labels: newLabels }) + + let linkTo = { search: filterQuery.toString() } + if (pathname) { linkTo.pathname = pathname } - if (filterQuery) { return ( - + {children} ) @@ -235,17 +237,6 @@ 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 = () => { } @@ -260,7 +251,12 @@ export default function ListReport(props) { plot={metricToPlot} >
- + {maybeRenderIconFor(listItem)} From 3a6e9ec8ad43a50abc69f9562a97f713b3946cfb Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 3 Jul 2024 12:49:57 +0300 Subject: [PATCH 04/32] pass addSearchFilter fn through props --- assets/js/dashboard/stats/modals/breakdown-modal.js | 3 +-- assets/js/dashboard/stats/modals/entry-pages.js | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index f60a339caa7e..5afb3a145b00 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,7 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; import * as api from '../../api' -import { addFilter } from '../../query' import debounce from 'debounce-promise' import { useMountedEffect } from '../../custom-hooks' import { trimURL } from '../../util/url' @@ -46,7 +45,7 @@ export default function BreakdownModal(props) { function withSearch(query) { if (search === '') { return query} - return addFilter(query, ['contains', reportInfo.dimension, [search]]) + return props.addSearchFilter(query, search) } function loadNextPage() { diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 7a707c2a8f4e..765fdc65581c 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -3,7 +3,7 @@ import { withRouter } from 'react-router-dom' import Modal from './modal' import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { hasGoalFilter } from "../../util/filters"; -import { parseQuery } from '../../query' +import { addFilter, parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal"; function EntryPagesModal(props) { @@ -23,6 +23,10 @@ function EntryPagesModal(props) { } } + const addSearchFilter = (query, s) => { + return addFilter(query, ['contains', reportInfo.dimension, [s]]) + } + const getMetrics = useCallback((query) => { if (hasGoalFilter(query)) { return [ @@ -53,6 +57,7 @@ function EntryPagesModal(props) { reportInfo={reportInfo} getMetrics={getMetrics} getFilterInfo={getFilterInfo} + addSearchFilter={addSearchFilter} /> ) From 872601112cf4af5b473415b6fbe306d54d8cf317 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 3 Jul 2024 13:03:56 +0300 Subject: [PATCH 05/32] pass fn props as useCallback --- assets/js/dashboard/stats/modals/entry-pages.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 765fdc65581c..56f8aea0ceef 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -16,16 +16,16 @@ function EntryPagesModal(props) { dimensionLabel: 'Entry page' } - const getFilterInfo = (listItem) => { + const getFilterInfo = useCallback((listItem) => { return { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - } + }, []) - const addSearchFilter = (query, s) => { + const addSearchFilter = useCallback((query, s) => { return addFilter(query, ['contains', reportInfo.dimension, [s]]) - } + }, []) const getMetrics = useCallback((query) => { if (hasGoalFilter(query)) { From 787e9fbd7daca95e8fd6aae8a3fdb4690094d6c5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 3 Jul 2024 15:34:08 +0300 Subject: [PATCH 06/32] add a function doc to BreakdownModal --- .../dashboard/stats/modals/breakdown-modal.js | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 5afb3a145b00..7d8eb6f1129f 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -8,6 +8,53 @@ import { FilterLink } from "../reports/list"; const LIMIT = 100 +// 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 ``, which has it's own +// specific URL pathname (e.g. /plausible.io/sources). On the initial render of the +// parent modal, the query should be parsed from the URL and passed into this +// component. That query object is not expected to change during that modal's +// lifecycle. + +// ### 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) + +// * `title` - title of the report to render on the top left. + +// * `endpoint` - The last part of the endpoint (e.g. "/sources") to query. this +// value will be appended to `/${props.site.domain}` + +// * `getMetrics` - a function taking the query object and returning the metrics +// that are be expected from the API. + +// * `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. + +// * `addSearchFilter` - a function that takes a query and the search string as +// arguments, and returns a new query with an additional search filter. export default function BreakdownModal(props) { const {site, query, reportInfo, getMetrics} = props const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` From 25df55d7d65838e33a1cb6cc35b3d5bcaa2fdf27 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 4 Jul 2024 18:26:41 +0300 Subject: [PATCH 07/32] refactor: create a Metric class --- .../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/locations/index.js | 30 +++- assets/js/dashboard/stats/pages/index.js | 30 +++- assets/js/dashboard/stats/reports/list.js | 34 ++--- assets/js/dashboard/stats/reports/metrics.js | 130 +++++++++++++----- .../dashboard/stats/sources/referrer-list.js | 12 +- .../js/dashboard/stats/sources/source-list.js | 21 ++- 10 files changed, 279 insertions(+), 91 deletions(-) diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js index 6161b9457f14..b9a344bf2ddb 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 ( @@ -118,13 +134,21 @@ 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) + } + return ( ) @@ -145,13 +169,21 @@ 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 ( ) @@ -176,13 +208,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/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 23f4a4bc2ded..bc5d6bf81fdd 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 ( !!metric) + } + return ( !!metric) + } + return ( !!metric) + } + return ( { - 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 '' @@ -201,11 +201,11 @@ export default function ListReport(props) { const metricLabels = getAvailableMetrics().map((metric) => { return (
- {metricLabelFor(metric, props.query)} + { metric.renderLabel(props.query) }
) }) @@ -240,7 +240,7 @@ export default function ListReport(props) { 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 (
@@ -280,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 1103323198c7..953bf9af54e0 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -14,42 +14,110 @@ 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' } - -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 +// Class representation of a metric. + +// 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 const createConversionRate = () => { + const renderValue = (value) => {`${value}%`} + const renderLabel = (_query) => "CR" + return new Metric({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 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 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}) +} + +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 3ebc1af6381d..06c9c375d6d9 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 806dfc8195a3..adfe5645d645 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 ( Date: Thu, 4 Jul 2024 20:27:16 +0300 Subject: [PATCH 08/32] Fixup: use Metric class for defining BreakdownModal metrics --- .../dashboard/stats/modals/breakdown-modal.js | 29 +++++++------------ .../js/dashboard/stats/modals/entry-pages.js | 21 +++++++------- assets/js/dashboard/stats/reports/metrics.js | 23 +++++++++++++-- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 7d8eb6f1129f..05858e490400 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -42,23 +42,22 @@ const LIMIT = 100 // * `title` - title of the report to render on the top left. -// * `endpoint` - The last part of the endpoint (e.g. "/sources") to query. this +// * `endpoint` - the last part of the endpoint (e.g. "/sources") to query. this // value will be appended to `/${props.site.domain}` -// * `getMetrics` - a function taking the query object and returning the metrics -// that are be expected from the API. +// * `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. -// * `addSearchFilter` - a function that takes a query and the search string as -// arguments, and returns a new query with an additional search filter. +// * `addSearchFilter` - a function that takes a query and the search string as +// arguments, and returns a new query with an additional search filter. export default function BreakdownModal(props) { - const {site, query, reportInfo, getMetrics} = props + const {site, query, reportInfo, metrics} = props const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` - const metrics = getMetrics(query) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') @@ -114,12 +113,8 @@ export default function BreakdownModal(props) { {metrics.map((metric) => { return ( - - {metric.formatter(item[metric.key])} + + {metric.renderValue(item[metric.key])} ) })} @@ -169,12 +164,8 @@ export default function BreakdownModal(props) { {metrics.map((metric) => { return ( - - {metric.label} + + {metric.renderLabel(query)} ) })} diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 56f8aea0ceef..f9f7b3326129 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -5,6 +5,7 @@ import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { hasGoalFilter } from "../../util/filters"; import { addFilter, parseQuery } from '../../query' import BreakdownModal from "./breakdown-modal"; +import * as metrics from '../reports/metrics' function EntryPagesModal(props) { const query = parseQuery(props.location.search, props.site) @@ -27,27 +28,27 @@ function EntryPagesModal(props) { return addFilter(query, ['contains', reportInfo.dimension, [s]]) }, []) - const getMetrics = useCallback((query) => { + function chooseMetrics() { if (hasGoalFilter(query)) { return [ - {key: 'total_visitors', label: 'Total visitors', formatter: numberFormatter}, - {key: 'visitors', label: 'Conversions', formatter: numberFormatter}, - {key: 'conversion_rate', label: 'CR', formatter: numberFormatter} + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() ] } if (query.period === 'realtime') { return [ - {key: 'visitors', label: "Current visitors", formatter: numberFormatter} + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) ] } return [ - {key: 'visitors', label: "Visitors", formatter: numberFormatter}, - {key: 'visits', label: "Total Entrances", formatter: numberFormatter}, - {key: 'visit_duration', label: "Visit Duration", formatter: durationFormatter} + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createVisits({renderLabel: (_query) => "Total Entrances" }), + metrics.createVisitDuration() ] - }, []) + } return ( @@ -55,7 +56,7 @@ function EntryPagesModal(props) { site={props.site} query={query} reportInfo={reportInfo} - getMetrics={getMetrics} + metrics={chooseMetrics()} getFilterInfo={getFilterInfo} addSearchFilter={addSearchFilter} /> diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 953bf9af54e0..c8d419ad9c66 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 } from "../../util/number-formatter" import React from "react" /*global BUILD_EXTRA*/ @@ -89,10 +89,10 @@ export const createVisitors = (props) => { return new Metric({...props, key: "visitors", renderValue, renderLabel}) } -export const createConversionRate = () => { +export const createConversionRate = (props) => { const renderValue = (value) => {`${value}%`} const renderLabel = (_query) => "CR" - return new Metric({key: "conversion_rate", renderLabel, renderValue}) + return new Metric({...props, key: "conversion_rate", renderLabel, renderValue}) } export const createPercentage = (props) => { @@ -118,6 +118,23 @@ export const createAverageRevenue = (props) => { 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}) +} + function renderNumberWithTooltip(value) { return {numberFormatter(value)} } \ No newline at end of file From 84533e9c8965be820bc4f323e7113e500f511046 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 13:41:35 +0300 Subject: [PATCH 09/32] keep revenueAvailable state in the Dashboard component --- assets/js/dashboard/historical.js | 2 +- assets/js/dashboard/index.js | 26 ++++++++++++++++- assets/js/dashboard/realtime.js | 4 +-- assets/js/dashboard/stats/graph/graph-util.js | 29 +++++-------------- assets/js/dashboard/stats/graph/top-stats.js | 4 +-- .../js/dashboard/stats/graph/visitor-graph.js | 14 +++++++-- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js index 8d946525a400..018d9ad78e86 100644 --- a/assets/js/dashboard/historical.js +++ b/assets/js/dashboard/historical.js @@ -31,7 +31,7 @@ function Historical(props) {
- +
diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index ca755c344344..6e6e9d6a5667 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -6,6 +6,7 @@ import Historical from './historical' import Realtime from './realtime' import {parseQuery} from './query' import * as api from './api' +import { getFiltersByKeyPrefix } from './util/filters'; export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded" @@ -13,6 +14,12 @@ function Dashboard(props) { const { location, site, loggedIn, currentUserRole } = props const [query, setQuery] = useState(parseQuery(location.search, site)) const [importedDataInView, setImportedDataInView] = useState(false) + + // `revenueAvailable` keeps track of whether the current query includes a + // non-empty goal filter set containing a single, or multiple revenue goals + // with the same currency. Can be used to decide whether to render revenue + // metrics in a dashboard report or not. + const [revenueAvailable, setRevenueAvailable] = useState(false) const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } @@ -24,13 +31,28 @@ function Dashboard(props) { } }, []) + useEffect(() => { + 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 + }) + + setRevenueAvailable(revenueGoalsInFilter.length > 0 && singleCurrency) + }, [query]) + useMountedEffect(() => { api.cancelAll() setQuery(parseQuery(location.search, site)) updateLastLoadTimestamp() }, [location.search]) - if (query.period === 'realtime') { return ( ) } else { @@ -51,6 +74,7 @@ function Dashboard(props) { lastLoadTimestamp={lastLoadTimestamp} importedDataInView={importedDataInView} updateImportedDataInView={setImportedDataInView} + revenueAvailable={revenueAvailable} /> ) } diff --git a/assets/js/dashboard/realtime.js b/assets/js/dashboard/realtime.js index 9eb83e8db543..ac03ecfdfd0a 100644 --- a/assets/js/dashboard/realtime.js +++ b/assets/js/dashboard/realtime.js @@ -13,7 +13,7 @@ import { withPinnedHeader } from './pinned-header-hoc'; import { statsBoxClass } from './index'; function Realtime(props) { - const {site, query, history, stuck, loggedIn, currentUserRole, lastLoadTimestamp} = props + const {site, query, history, stuck, loggedIn, currentUserRole, lastLoadTimestamp, revenueAvailable} = props const navClass = site.embedded ? 'relative' : 'sticky' return ( @@ -28,7 +28,7 @@ function Realtime(props) {
- +
diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index 59b96bf53377..fdbfaab253c1 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -1,41 +1,26 @@ import numberFormatter, {durationFormatter} from '../../util/number-formatter' -import { getFiltersByKeyPrefix, getGoalFilter } from '../../util/filters' +import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters' -export function getGraphableMetrics(query, site) { +export function getGraphableMetrics(query, revenueAvailable) { 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) { return ["visitors"] } else if (isRealtime) { return ["visitors", "pageviews"] - } else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) { + } else if (isGoalFilter && revenueAvailable) { 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/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index c8f51475f2b9..20a0218d6d25 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -66,7 +66,7 @@ function topStatNumberLong(name, value) { } export default function TopStats(props) { - const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp} = props + const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp, revenueAvailable} = props function tooltip(stat) { let statName = stat.name.toLowerCase() @@ -89,7 +89,7 @@ export default function TopStats(props) { } function canMetricBeGraphed(stat) { - const graphableMetrics = getGraphableMetrics(query, site) + const graphableMetrics = getGraphableMetrics(query, revenueAvailable) return stat.graph_metric && graphableMetrics.includes(stat.graph_metric) } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index 2b12588f82eb..f84e6f41ae6e 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -28,7 +28,7 @@ function fetchMainGraph(site, query, metric, interval) { } export default function VisitorGraph(props) { - const {site, query, lastLoadTimestamp} = props + const {site, query, lastLoadTimestamp, revenueAvailable} = props const isRealtime = query.period === 'realtime' const isDarkTheme = document.querySelector('html').classList.contains('dark') || false @@ -89,7 +89,7 @@ export default function VisitorGraph(props) { }) let metric = getStoredMetric() - const availableMetrics = getGraphableMetrics(query, site) + const availableMetrics = getGraphableMetrics(query, revenueAvailable) if (!availableMetrics.includes(metric)) { metric = availableMetrics[0] @@ -136,7 +136,15 @@ export default function VisitorGraph(props) { {(topStatsLoading || graphLoading) && renderLoader()}
- +
{graphRefreshing && renderLoader()} From 8e686ea65e6d52c1946713510a1c97e38b0d9134 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 15:12:00 +0300 Subject: [PATCH 10/32] move query context into a higher-order component --- .../dashboard/components/query-context-hoc.js | 74 +++++++++++++++++++ assets/js/dashboard/index.js | 62 ++++------------ 2 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 assets/js/dashboard/components/query-context-hoc.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 000000000000..8eb50787bf6c --- /dev/null +++ b/assets/js/dashboard/components/query-context-hoc.js @@ -0,0 +1,74 @@ +import React, { useState, useEffect} from "react" +import * as api from '../api' +import { useMountedEffect } from '../custom-hooks' +import { parseQuery } from "../query" +import { getFiltersByKeyPrefix } from '../util/filters' + +// 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. + +// * `revenueAvailable` - keeps track of whether the current query includes a +// non-empty goal filterset containing a single, or multiple revenue goals +// with the same currency. Can be used to decide whether to render revenue +// metrics in a dashboard report or not. + +// * `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 [revenueAvailable, setRevenueAvailable] = useState(false) + const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) + + const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } + + useEffect(() => { + document.addEventListener('tick', updateLastLoadTimestamp) + + return () => { + document.removeEventListener('tick', updateLastLoadTimestamp) + } + }, []) + + useEffect(() => { + 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 + }) + + setRevenueAvailable(revenueGoalsInFilter.length > 0 && singleCurrency) + }, [query]) + + useMountedEffect(() => { + api.cancelAll() + setQuery(parseQuery(location.search, site)) + updateLastLoadTimestamp() + }, [location.search]) + + return ( + + ) + } +} \ No newline at end of file diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index 6e6e9d6a5667..a0fb114a3321 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -1,57 +1,23 @@ -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 { getFiltersByKeyPrefix } from './util/filters'; +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) - - // `revenueAvailable` keeps track of whether the current query includes a - // non-empty goal filter set containing a single, or multiple revenue goals - // with the same currency. Can be used to decide whether to render revenue - // metrics in a dashboard report or not. - const [revenueAvailable, setRevenueAvailable] = useState(false) - const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) - const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } - - useEffect(() => { - document.addEventListener('tick', updateLastLoadTimestamp) - - return () => { - document.removeEventListener('tick', updateLastLoadTimestamp) - } - }, []) - - useEffect(() => { - 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 - }) - - setRevenueAvailable(revenueGoalsInFilter.length > 0 && singleCurrency) - }, [query]) - - useMountedEffect(() => { - api.cancelAll() - setQuery(parseQuery(location.search, site)) - updateLastLoadTimestamp() - }, [location.search]) + const { + site, + loggedIn, + currentUserRole, + query, + revenueAvailable, + importedDataInView, + updateImportedDataInView, + lastLoadTimestamp + } = props if (query.period === 'realtime') { return ( @@ -73,11 +39,11 @@ function Dashboard(props) { query={query} lastLoadTimestamp={lastLoadTimestamp} importedDataInView={importedDataInView} - updateImportedDataInView={setImportedDataInView} + updateImportedDataInView={updateImportedDataInView} revenueAvailable={revenueAvailable} /> ) } } -export default withRouter(Dashboard) +export default withRouter(withQueryContext(Dashboard)) From 6c7d6ca7978ade6cecfbded250c95bafc162e361 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 15:25:34 +0300 Subject: [PATCH 11/32] fix react key error in BreakdownModal --- assets/js/dashboard/stats/modals/breakdown-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 05858e490400..d2ada2df2508 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -164,7 +164,7 @@ export default function BreakdownModal(props) { {metrics.map((metric) => { return ( - + {metric.renderLabel(query)} ) From 1f00120dddc87f3b211fda19847f313d5a25157d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 15:34:03 +0300 Subject: [PATCH 12/32] use BreakdownModal in PropsModal --- assets/js/dashboard/stats/modals/props.js | 161 ++++++---------------- 1 file changed, 43 insertions(+), 118 deletions(-) diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 64fe66a97b57..1ae83c396c5f 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -1,137 +1,62 @@ -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"; function PropsModal(props) { - const site = props.site - const query = parseQuery(props.location.search, site) - - const propKey = props.location.pathname.split('/').filter(i => i).pop() + const {site, query, location, revenueAvailable} = props + const propKey = location.pathname.split('/').filter(i => i).pop() - const [loading, setLoading] = useState(true) - const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - const [page, setPage] = useState(1) - const [list, setList] = useState([]) + /*global BUILD_EXTRA*/ + const showRevenueMetrics = BUILD_EXTRA && revenueAvailable - useEffect(() => { - fetchData() - }, []) - - 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 reportInfo = { + title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), + dimension: propKey, + endpoint: `/custom-prop-values/${propKey}`, + dimensionLabel: propKey } - 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) + const getFilterInfo = useCallback((listItem) => { + return { + prefix: `${EVENT_PROPS_PREFIX}${propKey}`, + filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] + } + }, []) - return ( - <> -

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

+ const addSearchFilter = useCallback((query, s) => { + return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [s]]) + }, []) -
-
- - - - - - - - {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(m => !!m) } return ( - - {renderBody()} - {loading && renderLoading()} - {!loading && moreResultsAvailable && renderLoadMore()} + + ) } -export default withRouter(PropsModal) +export default withRouter(withQueryContext(PropsModal)) From aa7d19a214d913b5e9196cef98b5b21aac1020af Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 15:52:04 +0300 Subject: [PATCH 13/32] adjust EntryPagesModal to use query context --- assets/js/dashboard/stats/modals/breakdown-modal.js | 6 ++---- assets/js/dashboard/stats/modals/entry-pages.js | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index d2ada2df2508..c250bf8bc029 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -12,10 +12,8 @@ const LIMIT = 100 // i.e. a breakdown by a single (non-time) dimension, with a given set of metrics. // BreakdownModal is expected to be rendered inside a ``, which has it's own -// specific URL pathname (e.g. /plausible.io/sources). On the initial render of the -// parent modal, the query should be parsed from the URL and passed into this -// component. That query object is not expected to change during that modal's -// lifecycle. +// 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 diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index f9f7b3326129..3cacc5db72b7 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -1,14 +1,14 @@ import React, {useCallback} from "react"; import { withRouter } from 'react-router-dom' import Modal from './modal' -import numberFormatter, { durationFormatter } from '../../util/number-formatter' import { hasGoalFilter } from "../../util/filters"; -import { addFilter, parseQuery } from '../../query' +import { addFilter } from '../../query' import BreakdownModal from "./breakdown-modal"; import * as metrics from '../reports/metrics' +import withQueryContext from "../../components/query-context-hoc"; function EntryPagesModal(props) { - const query = parseQuery(props.location.search, props.site) + const { site, query } = props const reportInfo = { title: 'Entry Pages', @@ -51,9 +51,9 @@ function EntryPagesModal(props) { } return ( - + Date: Fri, 5 Jul 2024 17:10:51 +0300 Subject: [PATCH 14/32] fix variable name typo --- assets/js/dashboard/stats/graph/graph-util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index fdbfaab253c1..d01e1da1cb47 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -6,7 +6,7 @@ export function getGraphableMetrics(query, revenueAvailable) { 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"] From b5ec45f3533e95456f0c3b4b9662e73352c39575 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 5 Jul 2024 17:34:56 +0300 Subject: [PATCH 15/32] fixup: BreakdownModal function doc --- assets/js/dashboard/stats/modals/breakdown-modal.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index c250bf8bc029..4040d4d02484 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -38,10 +38,13 @@ const LIMIT = 100 // * `query` - a read-only query object representing the query state of the // dashboard (e.g. `filters`, `period`, `with_imported`, etc) -// * `title` - title of the report to render on the top left. +// * `reportInfo` - a map with the following required keys: -// * `endpoint` - the last part of the endpoint (e.g. "/sources") to query. this -// value will be appended to `/${props.site.domain}` +// * `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 From cd72fe09a1559f1a85a718d33bbed63cbfb8c1d4 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 01:17:41 +0300 Subject: [PATCH 16/32] use BreakdownModal in SourcesModal --- .../dashboard/stats/modals/breakdown-modal.js | 15 +- assets/js/dashboard/stats/modals/sources.js | 235 ++++++------------ assets/js/dashboard/stats/reports/metrics.js | 6 + 3 files changed, 89 insertions(+), 167 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 4040d4d02484..7856340b718e 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -56,8 +56,12 @@ const LIMIT = 100 // * `addSearchFilter` - a function that takes a query and the search string as // arguments, and returns a new query with an additional search filter. + +// ### Optional Props + +// * `renderIcon` - a function that renders an icon for the given list item. export default function BreakdownModal(props) { - const {site, query, reportInfo, metrics} = props + const {site, query, reportInfo, metrics, renderIcon} = props const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` const [loading, setLoading] = useState(true) @@ -67,7 +71,7 @@ export default function BreakdownModal(props) { const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) const fetchData = useCallback(debounce(() => { - api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1 }) + api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true }) .then((response) => { setLoading(false) setPage(1) @@ -100,10 +104,17 @@ export default function BreakdownModal(props) { setPage(page + 1) } + function maybeRenderIcon(item) { + if (typeof renderIcon === 'function') { + return renderIcon(item) + } + } + function renderRow(item) { return ( + { maybeRenderIcon(item) } this.setState({ loading: false, sources: sources.concat(response.results), moreResultsAvailable: response.results.length === 100 })) - } + const urlParts = location.pathname.split('/') + const currentView = urlParts[urlParts.length - 1] - componentDidMount() { - this.loadSources() - } + const reportInfo = VIEWS[currentView] - componentDidUpdate(prevProps) { - if (this.props.location.pathname !== prevProps.location.pathname) { - this.setState({ sources: [], loading: true }, this.loadSources.bind(this)) + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - } - - 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 '-' + }, []) + + const addSearchFilter = useCallback((query, s) => { + return addFilter(query, ['contains', reportInfo.dimension, [s]]) + }, []) + + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - } - formatDuration(source) { - if (typeof (source.visit_duration) === 'number') { - return durationFormatter(source.visit_duration) - } else { - return '-' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } - } - - icon(source) { - if (this.currentView() === 'sources') { + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createBounceRate(), + metrics.createVisitDuration() + ] + } + + let renderIcon + + if (currentView === 'sources') { + renderIcon = useCallback((source) => { 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()} -
- ) - } + }) + } + + return ( + + + + ) } -export default withRouter(SourcesModal) +export default withRouter(withQueryContext(SourcesModal)) diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index c8d419ad9c66..3b0d9ed289c6 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -135,6 +135,12 @@ export const createVisitDuration = (props) => { 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}) +} + function renderNumberWithTooltip(value) { return {numberFormatter(value)} } \ No newline at end of file From 413fdd01c149daf5c5ec119db85e401dd5f34fcf Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 02:33:34 +0300 Subject: [PATCH 17/32] use Breakdown modal in ReferrerDrilldownModal --- .../dashboard/stats/modals/breakdown-modal.js | 25 ++- .../stats/modals/referrer-drilldown.js | 200 ++++++------------ 2 files changed, 92 insertions(+), 133 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 7856340b718e..5d7afb6c4874 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -60,8 +60,14 @@ const LIMIT = 100 // ### 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. export default function BreakdownModal(props) { - const {site, query, reportInfo, metrics, renderIcon} = props + const {site, query, reportInfo, metrics, renderIcon, getExternalLinkURL} = props const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` const [loading, setLoading] = useState(true) @@ -110,10 +116,24 @@ export default function BreakdownModal(props) { } } + 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 ( diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index ff1e4caf5bf2..d5e0da616a5d 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"; - -class ReferrerDrilldownModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site) - } - } - - componentDidMount() { - const detailed = this.showExtra() - - 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) +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"; + +function ReferrerDrilldownModal(props) { + const { site, query, match } = props + + const reportInfo = { + title: "Referrer Drilldown", + dimension: 'referrer', + endpoint: `/referrers/${match.params.referrer}`, + dimensionLabel: "Referrer" } - showConversionRate() { - return hasGoalFilter(this.state.query) - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ['is', reportInfo.dimension, [listItem.name]] } - - if (this.showConversionRate()) { - return 'Conversions' + }, []) + + const addSearchFilter = useCallback((query, s) => { + return addFilter(query, ['contains', reportInfo.dimension, [s]]) + }, []) + + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - return 'Visitors' - } - - 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 '-' + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createBounceRate(), + metrics.createVisitDuration() + ] } - renderExternalLink(name) { - if (name !== 'Direct / None') { - return ( - - - - ) - } - } - - renderReferrerName(referrer) { - const filters = replaceFilterByPrefix(this.state.query, "referrer", ["is", "referrer", [referrer.name]]) - return ( - - - - {referrer.name} - - {this.renderExternalLink(referrer.name)} - - ) - } - - renderReferrer(referrer) { + const renderIcon = useCallback((listItem) => { 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)) From 280634a21b0cc86a836c14b108f446a97ef15f8d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 02:48:44 +0300 Subject: [PATCH 18/32] use BreakdownModal in PagesModal --- assets/js/dashboard/stats/modals/pages.js | 196 ++++++------------- assets/js/dashboard/stats/reports/metrics.js | 12 ++ 2 files changed, 68 insertions(+), 140 deletions(-) diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index c1819163ff73..c24dca7c9aa1 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"; - -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 - } - } - - componentDidMount() { - this.loadPages(); - } - - 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 }))) +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 PagesModal(props) { + const { site, query } = props + + const reportInfo = { + title: 'Top Pages', + dimension: 'page', + endpoint: '/pages', + dimensionLabel: 'Page url' } - 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' - } - - if (this.showConversionRate()) { - return 'Conversions' + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - - return 'Visitors' - } - - renderLoading() { - if (this.state.loading) { - return
- } else if (this.state.moreResultsAvailable) { - return ( -
- -
- ) + }, []) + + const addSearchFilter = useCallback((query, s) => { + return addFilter(query, ['contains', reportInfo.dimension, [s]]) + }, []) + + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - } - - 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
-
-
- ) + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createPageviews(), + metrics.createBounceRate(), + metrics.createTimeOnPage() + ] } - render() { - return ( - - {this.renderBody()} - {this.renderLoading()} - - ) - } + return ( + + + + ) } -export default withRouter(PagesModal) +export default withRouter(withQueryContext(PagesModal)) diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 3b0d9ed289c6..ba9480972914 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -141,6 +141,18 @@ export const createBounceRate = (props) => { 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}) +} + function renderNumberWithTooltip(value) { return {numberFormatter(value)} } \ No newline at end of file From 84a81be05b7204b065d240bdc9097293d6eb670a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 03:00:47 +0300 Subject: [PATCH 19/32] use BreakdownModal in ExitPagesModal --- .../js/dashboard/stats/modals/exit-pages.js | 178 ++++++------------ assets/js/dashboard/stats/reports/metrics.js | 12 +- 2 files changed, 64 insertions(+), 126 deletions(-) diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 25be36b3ac23..8801be078d7c 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 - } - } - - componentDidMount() { - this.loadPages(); - } - - 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)) +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' } - 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' + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] } - - if (this.showConversionRate()) { - return 'Conversions' + }, []) + + const addSearchFilter = useCallback((query, s) => { + return addFilter(query, ['contains', reportInfo.dimension, [s]]) + }, []) + + function chooseMetrics() { + if (hasGoalFilter(query)) { + return [ + metrics.createTotalVisitors(), + metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createConversionRate() + ] } - return 'Visitors' - } - - 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 ( -
- -
- ) + if (query.period === 'realtime') { + return [ + metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + ] } + + return [ + metrics.createVisitors({renderLabel: (_query) => "Visitors" }), + metrics.createVisits({renderLabel: (_query) => "Total Exits" }), + metrics.createExitRate() + ] } - 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/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index ba9480972914..0e75bf248799 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, { durationFormatter } from "../../util/number-formatter" +import numberFormatter, { durationFormatter, percentageFormatter } from "../../util/number-formatter" import React from "react" /*global BUILD_EXTRA*/ @@ -90,13 +90,13 @@ export const createVisitors = (props) => { } export const createConversionRate = (props) => { - const renderValue = (value) => {`${value}%`} + 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 renderValue = (value) => value const renderLabel = (_query) => "%" return new Metric({...props, key: "percentage", renderLabel, renderValue}) } @@ -153,6 +153,12 @@ export const createTimeOnPage = (props) => { 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 From 7bd867ed75d0e4fff59087b865fc4c194af76753 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 16:10:22 +0300 Subject: [PATCH 20/32] replace ModalTable with LocationsModal and use BreakdownModal in it --- assets/js/dashboard/router.js | 25 +--- .../dashboard/stats/modals/breakdown-modal.js | 23 ++-- .../dashboard/stats/modals/locations-modal.js | 75 +++++++++++ assets/js/dashboard/stats/modals/table.js | 120 ------------------ assets/js/dashboard/stats/reports/metrics.js | 2 +- 5 files changed, 94 insertions(+), 151 deletions(-) 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/router.js b/assets/js/dashboard/router.js index 8f591f62249d..742112a30540 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/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 5d7afb6c4874..f8c93ca4aec7 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -54,9 +54,6 @@ const LIMIT = 100 // is filtered by. If a list item is not supposed to be a filter link, this // function should return `null` for that item. -// * `addSearchFilter` - a function that takes a query and the search string as -// arguments, and returns a new query with an additional search filter. - // ### Optional Props // * `renderIcon` - a function that renders an icon for the given list item. @@ -66,9 +63,16 @@ const LIMIT = 100 // 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. export default function BreakdownModal(props) { - const {site, query, reportInfo, metrics, renderIcon, getExternalLinkURL} = props + const {site, query, reportInfo, metrics, renderIcon, getExternalLinkURL, searchEnabled} = props const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` + const isSearchEnabled = searchEnabled === false ? false : true const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') @@ -101,8 +105,11 @@ export default function BreakdownModal(props) { } function withSearch(query) { - if (search === '') { return query} - return props.addSearchFilter(query, search) + if (isSearchEnabled && search !== '') { + return props.addSearchFilter(query, search) + } + + return query } function loadNextPage() { @@ -175,12 +182,12 @@ export default function BreakdownModal(props) { <>

{ reportInfo.title }

- { setSearch(e.target.value) }} - /> + />}
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 000000000000..8fe31f107676 --- /dev/null +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -0,0 +1,75 @@ +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(m => !!m) + } + + let renderIcon + + if (currentView === 'countries') { + renderIcon = useCallback((listItem) => {listItem.flag}) + } else { + renderIcon = useCallback((listItem) => {listItem.country_flag}) + } + + return ( + + + + ) +} + +export default withRouter(withQueryContext(LocationsModal)) diff --git a/assets/js/dashboard/stats/modals/table.js b/assets/js/dashboard/stats/modals/table.js deleted file mode 100644 index fc4861776a10..000000000000 --- 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/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 0e75bf248799..db5704e66f57 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -120,7 +120,7 @@ export const createAverageRevenue = (props) => { export const createTotalVisitors = (props) => { const renderValue = renderNumberWithTooltip - const renderLabel = (_query) => "Total visitors" + const renderLabel = (_query) => "Total Visitors" return new Metric({...props, key: "total_visitors", renderValue, renderLabel}) } From 4d61b61b3c68385e7e755a548dba4e86e6e240b3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 21:44:55 +0300 Subject: [PATCH 21/32] use BreakdownModal in Conversions --- .../dashboard/stats/modals/breakdown-modal.js | 26 ++- .../js/dashboard/stats/modals/conversions.js | 164 ++++++------------ 2 files changed, 79 insertions(+), 111 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index f8c93ca4aec7..0083b5d00b4c 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -69,8 +69,26 @@ const LIMIT = 100 // * `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(props) { - const {site, query, reportInfo, metrics, renderIcon, getExternalLinkURL, searchEnabled} = props + const { + site, + query, + reportInfo, + metrics, + renderIcon, + getExternalLinkURL, + searchEnabled, + afterFetchData, + afterFetchNextPage + } = props + const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` const isSearchEnabled = searchEnabled === false ? false : true @@ -83,6 +101,9 @@ export default function BreakdownModal(props) { const fetchData = useCallback(debounce(() => { api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true }) .then((response) => { + if (typeof afterFetchData === 'function') { + afterFetchData(response) + } setLoading(false) setPage(1) setResults(response.results) @@ -97,6 +118,9 @@ export default function BreakdownModal(props) { if (page > 1) { api.get(endpoint, withSearch(query), { limit: LIMIT, page }) .then((response) => { + if (typeof afterFetchNextPage === 'function') { + afterFetchNextPage(response) + } setLoading(false) setResults(results.concat(response.results)) setMoreResultsAvailable(response.results.length === LIMIT) diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 38efbfa7d032..3ace9ae7605a 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 +/*global BUILD_EXTRA*/ function ConversionsModal(props) { - const site = props.site - const query = parseQuery(props.location.search, site) - - const [loading, setLoading] = useState(true) - const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - const [page, setPage] = useState(1) - const [list, setList] = useState([]) - - useEffect(() => { - fetchData() - }, []) - - 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) - }) + const { site, query } = props + const [showRevenue, setShowRevenue] = useState(false) + + const reportInfo = { + title: 'Goal Conversions', + dimension: 'goal', + endpoint: '/conversions', + dimensionLabel: "Goal" } - function loadMore() { - setLoading(true) - fetchData() - } - - function renderLoadMore() { - return ( -
- -
- ) - } - - 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 && } - - ) - } + const getFilterInfo = useCallback((listItem) => { + return { + prefix: reportInfo.dimension, + filter: ["is", reportInfo.dimension, [listItem.name]] + } + }, []) - function renderLoading() { - return
+ function chooseMetrics() { + return [ + metrics.createVisitors({renderLabel: (_query) => "Uniques"}), + metrics.createEvents({renderLabel: (_query) => "Total"}), + metrics.createConversionRate(), + showRevenue && metrics.createAverageRevenue(), + showRevenue && metrics.createTotalRevenue(), + ].filter(m => !!m) } - function renderBody() { - const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue) - - return ( - <> -

Goal Conversions

- -
-
- - - - - - - - {hasRevenue && } - {hasRevenue && } - - - - {list.map((item) => renderListItem(item, hasRevenue))} - -
GoalUniquesTotalCRRevenueAverage
-
- - ) + // 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]) + + // 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 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)) From e599077094a7f4f9f06db300f4012ae09d7c1d8d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 23:14:48 +0300 Subject: [PATCH 22/32] make sure next pages are loaded with 'detailed: true' --- assets/js/dashboard/stats/modals/breakdown-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 0083b5d00b4c..f4e61800912b 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -116,7 +116,7 @@ export default function BreakdownModal(props) { function fetchNextPage() { if (page > 1) { - api.get(endpoint, withSearch(query), { limit: LIMIT, page }) + api.get(endpoint, withSearch(query), { limit: LIMIT, page, detailed: true }) .then((response) => { if (typeof afterFetchNextPage === 'function') { afterFetchNextPage(response) From db4903a681810388c9610a9c5865feba5162d113 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 7 Jul 2024 23:22:58 +0300 Subject: [PATCH 23/32] replace loading spinner logic in BreakdownModal --- .../dashboard/stats/modals/breakdown-modal.js | 134 +++++++++++------- 1 file changed, 80 insertions(+), 54 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index f4e61800912b..c39a0267c77d 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -7,6 +7,7 @@ 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. @@ -92,6 +93,7 @@ export default function BreakdownModal(props) { const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` const isSearchEnabled = searchEnabled === false ? false : true + const [initialLoading, setInitialLoading] = useState(true) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [results, setResults] = useState([]) @@ -104,6 +106,7 @@ export default function BreakdownModal(props) { if (typeof afterFetchData === 'function') { afterFetchData(response) } + setInitialLoading(false) setLoading(false) setPage(1) setResults(response.results) @@ -111,7 +114,11 @@ export default function BreakdownModal(props) { }) }, 200), [search]) - useEffect(() => { fetchData() }, [search]) + useEffect(() => { + setLoading(true) + fetchData() + }, [search]) + useMountedEffect(() => { fetchNextPage() }, [page]) function fetchNextPage() { @@ -186,69 +193,88 @@ export default function BreakdownModal(props) { ) } - function renderLoading() { - if (loading) { - return
- } else if (moreResultsAvailable) { - return ( -
- -
- ) - } + function renderInitialLoadingSpinner() { + return ( +
+
+
+ ) } - function renderBody() { + function renderSmallLoadingSpinner() { + return ( +
+ ) + } + + function renderLoadMoreButton() { + return ( +
+ +
+ ) + } + + function renderSearchInput() { + return ( + { setSearch(e.target.value) }} + /> + ) + } + + function renderModalBody() { if (results) { return ( - <> -
-

{ reportInfo.title }

- { isSearchEnabled && { setSearch(e.target.value) }} - />} -
- -
-
- - - - - - {metrics.map((metric) => { - return ( - - ) - })} - - - - { results.map(renderRow) } - -
- {reportInfo.dimensionLabel} - - {metric.renderLabel(query)} -
-
- +
+ + + + + + {metrics.map((metric) => { + return ( + + ) + })} + + + + { results.map(renderRow) } + +
+ {reportInfo.dimensionLabel} + + {metric.renderLabel(query)} +
+
) } } return (
- { renderBody() } - { renderLoading() } +
+
+

{ reportInfo.title }

+ { !initialLoading && loading && renderSmallLoadingSpinner() } +
+ { isSearchEnabled && renderSearchInput()} +
+
+
+ { initialLoading && renderInitialLoadingSpinner() } + { !initialLoading && renderModalBody() } + { !loading && moreResultsAvailable && renderLoadMoreButton() } +
) } From 3029526d21b93d93320e8872448ec0e568ff26ac Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 8 Jul 2024 12:20:33 +0300 Subject: [PATCH 24/32] fix two flaky tests --- .../controllers/admin_controller_test.exs | 14 +++++++------- .../api/external_stats_controller/query_test.exs | 9 +++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/test/plausible_web/controllers/admin_controller_test.exs b/test/plausible_web/controllers/admin_controller_test.exs index 2985320c0861..016bb91bb9b5 100644 --- a/test/plausible_web/controllers/admin_controller_test.exs +++ b/test/plausible_web/controllers/admin_controller_test.exs @@ -37,26 +37,26 @@ defmodule PlausibleWeb.AdminControllerTest do } do patch_env(:super_admin_user_ids, [user.id]) - s1 = insert(:site) + s1 = insert(:site, inserted_at: ~N[2024-01-01 00:00:00]) insert_list(3, :site_membership, site: s1) - s2 = insert(:site) + s2 = insert(:site, inserted_at: ~N[2024-01-02 00:00:00]) insert_list(3, :site_membership, site: s2) - s3 = insert(:site) + s3 = insert(:site, inserted_at: ~N[2024-01-03 00:00:00]) insert_list(3, :site_membership, site: s3) conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"}) page1_html = html_response(conn1, 200) - assert page1_html =~ s1.domain + assert page1_html =~ s3.domain assert page1_html =~ s2.domain - refute page1_html =~ s3.domain + refute page1_html =~ s1.domain conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"}) page2_html = html_response(conn2, 200) - refute page2_html =~ s1.domain + refute page2_html =~ s3.domain refute page2_html =~ s2.domain - assert page2_html =~ s3.domain + assert page2_html =~ s1.domain end end 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 331687e1f0ae..1b7ba90e0273 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 @@ -2878,10 +2878,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 From 37326b9f72db2f950545a10994307ffcd82d59f7 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 8 Jul 2024 12:22:17 +0300 Subject: [PATCH 25/32] unfocus search input element on Escape keyup event --- .../dashboard/stats/modals/breakdown-modal.js | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index c39a0267c77d..e8ab64cbbce2 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import * as api from '../../api' import debounce from 'debounce-promise' @@ -99,6 +99,31 @@ export default function BreakdownModal(props) { const [results, setResults] = useState([]) const [page, setPage] = useState(1) const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) + const searchBoxRef = useRef(null) + + useMountedEffect(() => { fetchNextPage() }, [page]) + + useEffect(() => { + setLoading(true) + fetchData() + }, [search]) + + useEffect(() => { + 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(debounce(() => { api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true }) @@ -113,13 +138,6 @@ export default function BreakdownModal(props) { setMoreResultsAvailable(response.results.length === LIMIT) }) }, 200), [search]) - - useEffect(() => { - setLoading(true) - fetchData() - }, [search]) - - useMountedEffect(() => { fetchNextPage() }, [page]) function fetchNextPage() { if (page > 1) { @@ -220,6 +238,7 @@ export default function BreakdownModal(props) { function renderSearchInput() { return ( Date: Mon, 8 Jul 2024 12:42:07 +0300 Subject: [PATCH 26/32] ignore Escape keyup handling when search disabled --- assets/js/dashboard/stats/modals/breakdown-modal.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index e8ab64cbbce2..8d99a15c4fa5 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -109,6 +109,8 @@ export default function BreakdownModal(props) { }, [search]) useEffect(() => { + if (!isSearchEnabled) { return } + const searchBox = searchBoxRef.current const handleKeyUp = (event) => { From 601b849e2796a5739e2d3752a497cbb2f8609634 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 8 Jul 2024 15:03:09 +0300 Subject: [PATCH 27/32] Review suggestion: remove redundant state --- .../dashboard/components/query-context-hoc.js | 24 ------------------- assets/js/dashboard/historical.js | 2 +- assets/js/dashboard/index.js | 3 --- assets/js/dashboard/query.js | 21 ++++++++++++++++ assets/js/dashboard/realtime.js | 4 ++-- assets/js/dashboard/stats/graph/graph-util.js | 5 ++-- assets/js/dashboard/stats/graph/top-stats.js | 4 ++-- .../js/dashboard/stats/graph/visitor-graph.js | 5 ++-- assets/js/dashboard/stats/modals/props.js | 5 ++-- 9 files changed, 34 insertions(+), 39 deletions(-) diff --git a/assets/js/dashboard/components/query-context-hoc.js b/assets/js/dashboard/components/query-context-hoc.js index 8eb50787bf6c..3dd65be804c0 100644 --- a/assets/js/dashboard/components/query-context-hoc.js +++ b/assets/js/dashboard/components/query-context-hoc.js @@ -2,7 +2,6 @@ import React, { useState, useEffect} from "react" import * as api from '../api' import { useMountedEffect } from '../custom-hooks' import { parseQuery } from "../query" -import { getFiltersByKeyPrefix } from '../util/filters' // A Higher-Order component that tracks `query` state, and additional context // related to it, such as: @@ -11,11 +10,6 @@ import { getFiltersByKeyPrefix } from '../util/filters' // `updateImportedDataInView` prop will be passed into the WrappedComponent // and allows changing that according to responses from the API. -// * `revenueAvailable` - keeps track of whether the current query includes a -// non-empty goal filterset containing a single, or multiple revenue goals -// with the same currency. Can be used to decide whether to render revenue -// metrics in a dashboard report or not. - // * `lastLoadTimestamp` - used for displaying a tooltip with time passed since // the last update in realtime components. @@ -25,7 +19,6 @@ export default function withQueryContext(WrappedComponent) { const [query, setQuery] = useState(parseQuery(location.search, site)) const [importedDataInView, setImportedDataInView] = useState(false) - const [revenueAvailable, setRevenueAvailable] = useState(false) const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date()) const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) } @@ -38,22 +31,6 @@ export default function withQueryContext(WrappedComponent) { } }, []) - useEffect(() => { - 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 - }) - - setRevenueAvailable(revenueGoalsInFilter.length > 0 && singleCurrency) - }, [query]) - useMountedEffect(() => { api.cancelAll() setQuery(parseQuery(location.search, site)) @@ -64,7 +41,6 @@ export default function withQueryContext(WrappedComponent) {
- +
diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index a0fb114a3321..158003b88ce4 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -13,7 +13,6 @@ function Dashboard(props) { loggedIn, currentUserRole, query, - revenueAvailable, importedDataInView, updateImportedDataInView, lastLoadTimestamp @@ -27,7 +26,6 @@ function Dashboard(props) { currentUserRole={currentUserRole} query={query} lastLoadTimestamp={lastLoadTimestamp} - revenueAvailable={revenueAvailable} /> ) } else { @@ -40,7 +38,6 @@ function Dashboard(props) { lastLoadTimestamp={lastLoadTimestamp} importedDataInView={importedDataInView} updateImportedDataInView={updateImportedDataInView} - revenueAvailable={revenueAvailable} /> ) } diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index eef98f7519ce..d3b050eacdca 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'; @@ -138,6 +139,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 ac03ecfdfd0a..9df8e24ce97b 100644 --- a/assets/js/dashboard/realtime.js +++ b/assets/js/dashboard/realtime.js @@ -13,7 +13,7 @@ import { withPinnedHeader } from './pinned-header-hoc'; import { statsBoxClass } from './index'; function Realtime(props) { - const {site, query, history, stuck, loggedIn, currentUserRole, lastLoadTimestamp, revenueAvailable} = props + const {site, query, history, stuck, loggedIn, currentUserRole, lastLoadTimestamp} = props const navClass = site.embedded ? 'relative' : 'sticky' return ( @@ -28,7 +28,7 @@ function Realtime(props) {
- +
diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index d01e1da1cb47..4b4cc0be408c 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -1,7 +1,8 @@ import numberFormatter, {durationFormatter} from '../../util/number-formatter' import { getFiltersByKeyPrefix, hasGoalFilter } from '../../util/filters' +import { revenueAvailable } from '../../query' -export function getGraphableMetrics(query, revenueAvailable) { +export function getGraphableMetrics(query, site) { const isRealtime = query.period === 'realtime' const isGoalFilter = hasGoalFilter(query) const isPageFilter = getFiltersByKeyPrefix(query, "page").length > 0 @@ -10,7 +11,7 @@ export function getGraphableMetrics(query, revenueAvailable) { return ["visitors"] } else if (isRealtime) { return ["visitors", "pageviews"] - } else if (isGoalFilter && revenueAvailable) { + } else if (isGoalFilter && revenueAvailable(query, site)) { return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"] } else if (isGoalFilter) { return ["visitors", "events", "conversion_rate"] diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index 20a0218d6d25..c8f51475f2b9 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -66,7 +66,7 @@ function topStatNumberLong(name, value) { } export default function TopStats(props) { - const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp, revenueAvailable} = props + const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp} = props function tooltip(stat) { let statName = stat.name.toLowerCase() @@ -89,7 +89,7 @@ export default function TopStats(props) { } function canMetricBeGraphed(stat) { - const graphableMetrics = getGraphableMetrics(query, revenueAvailable) + const graphableMetrics = getGraphableMetrics(query, site) return stat.graph_metric && graphableMetrics.includes(stat.graph_metric) } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index f84e6f41ae6e..cc371fe599f6 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -28,7 +28,7 @@ function fetchMainGraph(site, query, metric, interval) { } export default function VisitorGraph(props) { - const {site, query, lastLoadTimestamp, revenueAvailable} = props + const {site, query, lastLoadTimestamp} = props const isRealtime = query.period === 'realtime' const isDarkTheme = document.querySelector('html').classList.contains('dark') || false @@ -89,7 +89,7 @@ export default function VisitorGraph(props) { }) let metric = getStoredMetric() - const availableMetrics = getGraphableMetrics(query, revenueAvailable) + const availableMetrics = getGraphableMetrics(query, site) if (!availableMetrics.includes(metric)) { metric = availableMetrics[0] @@ -143,7 +143,6 @@ export default function VisitorGraph(props) { onMetricUpdate={onMetricUpdate} tooltipBoundary={topStatsBoundary.current} lastLoadTimestamp={lastLoadTimestamp} - revenueAvailable={revenueAvailable} />
diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 1ae83c396c5f..cf7e0a00e56e 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -8,13 +8,14 @@ import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; 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, query, location, revenueAvailable} = props + const {site, query, location} = props const propKey = location.pathname.split('/').filter(i => i).pop() /*global BUILD_EXTRA*/ - const showRevenueMetrics = BUILD_EXTRA && revenueAvailable + const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) const reportInfo = { title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), From 98e6278cea447acf7ad030ef4e65730567b7dc7f Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 9 Jul 2024 13:15:32 +0300 Subject: [PATCH 28/32] do not fetch data on every search input change --- assets/js/dashboard/custom-hooks.js | 14 ++++++++++- .../dashboard/stats/modals/breakdown-modal.js | 24 ++++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/assets/js/dashboard/custom-hooks.js b/assets/js/dashboard/custom-hooks.js index adb955f15c30..4bbe92bdcc50 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/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 8d99a15c4fa5..9cfac5d63a89 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,8 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import * as api from '../../api' -import debounce from 'debounce-promise' -import { useMountedEffect } from '../../custom-hooks' +import { useDebouncedEffect, useMountedEffect } from '../../custom-hooks' import { trimURL } from '../../util/url' import { FilterLink } from "../reports/list"; @@ -95,6 +94,7 @@ export default function BreakdownModal(props) { 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) @@ -103,10 +103,11 @@ export default function BreakdownModal(props) { useMountedEffect(() => { fetchNextPage() }, [page]) - useEffect(() => { - setLoading(true) - fetchData() - }, [search]) + useDebouncedEffect(() => { + setSearch(searchInput) + }, [searchInput], 300) + + useEffect(() => { fetchData() }, [search]) useEffect(() => { if (!isSearchEnabled) { return } @@ -127,7 +128,8 @@ export default function BreakdownModal(props) { } }, []) - const fetchData = useCallback(debounce(() => { + const fetchData = useCallback(() => { + setLoading(true) api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true }) .then((response) => { if (typeof afterFetchData === 'function') { @@ -139,7 +141,7 @@ export default function BreakdownModal(props) { setResults(response.results) setMoreResultsAvailable(response.results.length === LIMIT) }) - }, 200), [search]) + }, [search]) function fetchNextPage() { if (page > 1) { @@ -237,6 +239,10 @@ export default function BreakdownModal(props) { ) } + function handleInputChange(e) { + setSearchInput(e.target.value) + } + function renderSearchInput() { return ( { setSearch(e.target.value) }} + onChange={handleInputChange} /> ) } From 0efc3c9422a8470784c53484458b5fd8ccf5ac3d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 9 Jul 2024 13:33:01 +0300 Subject: [PATCH 29/32] use longer variable names --- assets/js/dashboard/stats/modals/conversions.js | 2 +- assets/js/dashboard/stats/modals/entry-pages.js | 4 ++-- assets/js/dashboard/stats/modals/exit-pages.js | 4 ++-- assets/js/dashboard/stats/modals/locations-modal.js | 2 +- assets/js/dashboard/stats/modals/pages.js | 4 ++-- assets/js/dashboard/stats/modals/props.js | 6 +++--- assets/js/dashboard/stats/modals/referrer-drilldown.js | 4 ++-- assets/js/dashboard/stats/modals/sources.js | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 3ace9ae7605a..112927df6950 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -33,7 +33,7 @@ function ConversionsModal(props) { metrics.createConversionRate(), showRevenue && metrics.createAverageRevenue(), showRevenue && metrics.createTotalRevenue(), - ].filter(m => !!m) + ].filter(metric => !!metric) } // After a successful API response, we want to scan the rows of the diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 3cacc5db72b7..03171ee8f009 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -24,8 +24,8 @@ function EntryPagesModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', reportInfo.dimension, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) }, []) function chooseMetrics() { diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 8801be078d7c..55ea1405eac5 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -24,8 +24,8 @@ function ExitPagesModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', reportInfo.dimension, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) }, []) function chooseMetrics() { diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index 8fe31f107676..1a954796463e 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -46,7 +46,7 @@ function LocationsModal(props) { return [ metrics.createVisitors({renderLabel: (_query) => "Visitors" }), currentView === 'countries' && metrics.createPercentage() - ].filter(m => !!m) + ].filter(metric => !!metric) } let renderIcon diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index c24dca7c9aa1..739b38de959f 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -24,8 +24,8 @@ function PagesModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', reportInfo.dimension, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) }, []) function chooseMetrics() { diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index cf7e0a00e56e..d77359d81517 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -31,8 +31,8 @@ function PropsModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]]) }, []) function chooseMetrics() { @@ -43,7 +43,7 @@ function PropsModal(props) { !hasGoalFilter(query) && metrics.createPercentage(), showRevenueMetrics && metrics.createAverageRevenue(), showRevenueMetrics && metrics.createTotalRevenue(), - ].filter(m => !!m) + ].filter(metric => !!metric) } return ( diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index d5e0da616a5d..04b4a61b4f64 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -25,8 +25,8 @@ function ReferrerDrilldownModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', reportInfo.dimension, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) }, []) function chooseMetrics() { diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 946a6ec48101..6e79d49eb81b 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -32,8 +32,8 @@ function SourcesModal(props) { } }, []) - const addSearchFilter = useCallback((query, s) => { - return addFilter(query, ['contains', reportInfo.dimension, [s]]) + const addSearchFilter = useCallback((query, searchString) => { + return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) }, []) function chooseMetrics() { From c739005e371db27aa4be0aac59e8e2a0e01a35b1 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 9 Jul 2024 14:35:53 +0300 Subject: [PATCH 30/32] do not define renderIcon callbacks conditionally --- .../dashboard/stats/modals/locations-modal.js | 12 ++--- assets/js/dashboard/stats/modals/sources.js | 49 +++++++++++-------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index 1a954796463e..c8bab2a66bf7 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -49,13 +49,11 @@ function LocationsModal(props) { ].filter(metric => !!metric) } - let renderIcon - - if (currentView === 'countries') { - renderIcon = useCallback((listItem) => {listItem.flag}) - } else { - renderIcon = useCallback((listItem) => {listItem.country_flag}) - } + const renderIcon = useCallback((listItem) => { + return ( + {listItem.country_flag || listItem.flag} + ) + }, []) return ( diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 6e79d49eb81b..c5a31d61e78b 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -9,12 +9,32 @@ import * as metrics from "../reports/metrics"; import { addFilter } from "../../query"; const VIEWS = { - sources: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'}, - utm_mediums: {title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium'}, - utm_sources: {title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source'}, - utm_campaigns: {title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign'}, - utm_contents: {title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content'}, - utm_terms: {title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term'}, + sources: { + info: {title: 'Top Sources', dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source'}, + renderIcon: (listItem) => { + return ( + + ) + } + }, + 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'} + }, } function SourcesModal(props) { @@ -23,7 +43,7 @@ function SourcesModal(props) { const urlParts = location.pathname.split('/') const currentView = urlParts[urlParts.length - 1] - const reportInfo = VIEWS[currentView] + const reportInfo = VIEWS[currentView].info const getFilterInfo = useCallback((listItem) => { return { @@ -58,19 +78,6 @@ function SourcesModal(props) { ] } - let renderIcon - - if (currentView === 'sources') { - renderIcon = useCallback((source) => { - return ( - - ) - }) - } - return ( ) From 016245f007ea640150642ac46b8c54d8249b5140 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 9 Jul 2024 14:41:28 +0300 Subject: [PATCH 31/32] deconstruct props in function header of BreakdownModal --- .../dashboard/stats/modals/breakdown-modal.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 9cfac5d63a89..62ca94c44c2d 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -76,19 +76,19 @@ const MIN_HEIGHT_PX = 500 // * `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(props) { - const { - site, - query, - reportInfo, - metrics, - renderIcon, - getExternalLinkURL, - searchEnabled, - afterFetchData, - afterFetchNextPage - } = props - +export default function BreakdownModal({ + site, + query, + reportInfo, + metrics, + renderIcon, + getExternalLinkURL, + searchEnabled, + afterFetchData, + afterFetchNextPage, + addSearchFilter, + getFilterInfo +}) { const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` const isSearchEnabled = searchEnabled === false ? false : true @@ -159,7 +159,7 @@ export default function BreakdownModal(props) { function withSearch(query) { if (isSearchEnabled && search !== '') { - return props.addSearchFilter(query, search) + return addSearchFilter(query, search) } return query @@ -198,7 +198,7 @@ export default function BreakdownModal(props) { {trimURL(item.name, 40)} From 78e44fa256db92b32c2d4b31c6fe5709f71b90fd Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 9 Jul 2024 14:46:49 +0300 Subject: [PATCH 32/32] refactor searchEnabled being true by default --- assets/js/dashboard/stats/modals/breakdown-modal.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 62ca94c44c2d..5d3a2ecda601 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -83,14 +83,13 @@ export default function BreakdownModal({ metrics, renderIcon, getExternalLinkURL, - searchEnabled, + searchEnabled = true, afterFetchData, afterFetchNextPage, addSearchFilter, getFilterInfo }) { const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` - const isSearchEnabled = searchEnabled === false ? false : true const [initialLoading, setInitialLoading] = useState(true) const [loading, setLoading] = useState(true) @@ -110,7 +109,7 @@ export default function BreakdownModal({ useEffect(() => { fetchData() }, [search]) useEffect(() => { - if (!isSearchEnabled) { return } + if (!searchEnabled) { return } const searchBox = searchBoxRef.current @@ -158,7 +157,7 @@ export default function BreakdownModal({ } function withSearch(query) { - if (isSearchEnabled && search !== '') { + if (searchEnabled && search !== '') { return addSearchFilter(query, search) } @@ -294,7 +293,7 @@ export default function BreakdownModal({

{ reportInfo.title }

{ !initialLoading && loading && renderSmallLoadingSpinner() }
- { isSearchEnabled && renderSearchInput()} + { searchEnabled && renderSearchInput()}