Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement search in Details views #4318

Merged
merged 34 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
be9a185
Create a new BreakdownModal component and use it for Entry Pages
RobertJoonas Jul 2, 2024
d0094a7
Add search functionality into the new component
RobertJoonas Jul 2, 2024
caac244
Adjust FilterLink component and use it in BreakdownModal
RobertJoonas Jul 2, 2024
3a6e9ec
pass addSearchFilter fn through props
RobertJoonas Jul 3, 2024
8726011
pass fn props as useCallback
RobertJoonas Jul 3, 2024
787e9fb
add a function doc to BreakdownModal
RobertJoonas Jul 3, 2024
25df55d
refactor: create a Metric class
RobertJoonas Jul 4, 2024
b41ba07
Fixup: use Metric class for defining BreakdownModal metrics
RobertJoonas Jul 4, 2024
84533e9
keep revenueAvailable state in the Dashboard component
RobertJoonas Jul 5, 2024
8e686ea
move query context into a higher-order component
RobertJoonas Jul 5, 2024
6c7d6ca
fix react key error in BreakdownModal
RobertJoonas Jul 5, 2024
1f00120
use BreakdownModal in PropsModal
RobertJoonas Jul 5, 2024
aa7d19a
adjust EntryPagesModal to use query context
RobertJoonas Jul 5, 2024
a918716
fix variable name typo
RobertJoonas Jul 5, 2024
b5ec45f
fixup: BreakdownModal function doc
RobertJoonas Jul 5, 2024
cd72fe0
use BreakdownModal in SourcesModal
RobertJoonas Jul 6, 2024
413fdd0
use Breakdown modal in ReferrerDrilldownModal
RobertJoonas Jul 6, 2024
280634a
use BreakdownModal in PagesModal
RobertJoonas Jul 6, 2024
84a81be
use BreakdownModal in ExitPagesModal
RobertJoonas Jul 7, 2024
7bd867e
replace ModalTable with LocationsModal and use BreakdownModal in it
RobertJoonas Jul 7, 2024
4d61b61
use BreakdownModal in Conversions
RobertJoonas Jul 7, 2024
e599077
make sure next pages are loaded with 'detailed: true'
RobertJoonas Jul 7, 2024
db4903a
replace loading spinner logic in BreakdownModal
RobertJoonas Jul 7, 2024
3029526
fix two flaky tests
RobertJoonas Jul 8, 2024
37326b9
unfocus search input element on Escape keyup event
RobertJoonas Jul 8, 2024
e2f3eeb
ignore Escape keyup handling when search disabled
RobertJoonas Jul 8, 2024
601b849
Review suggestion: remove redundant state
RobertJoonas Jul 8, 2024
98e6278
do not fetch data on every search input change
RobertJoonas Jul 9, 2024
c76ec9c
Merge branch 'master' into modal-search
RobertJoonas Jul 9, 2024
0efc3c9
use longer variable names
RobertJoonas Jul 9, 2024
c739005
do not define renderIcon callbacks conditionally
RobertJoonas Jul 9, 2024
016245f
deconstruct props in function header of BreakdownModal
RobertJoonas Jul 9, 2024
78e44fa
refactor searchEnabled being true by default
RobertJoonas Jul 9, 2024
b7b4cb6
Merge remote-tracking branch 'origin/master' into modal-search
RobertJoonas Jul 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions assets/js/dashboard/components/query-context-hoc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useState, useEffect} from "react"
import * as api from '../api'
import { useMountedEffect } from '../custom-hooks'
import { parseQuery } from "../query"

// A Higher-Order component that tracks `query` state, and additional context
// related to it, such as:

// * `importedDataInView` - simple state with a `false` default. An
// `updateImportedDataInView` prop will be passed into the WrappedComponent
// and allows changing that according to responses from the API.

// * `lastLoadTimestamp` - used for displaying a tooltip with time passed since
// the last update in realtime components.

export default function withQueryContext(WrappedComponent) {
return (props) => {
const { site, location } = props

const [query, setQuery] = useState(parseQuery(location.search, site))
const [importedDataInView, setImportedDataInView] = useState(false)
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())

const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) }

useEffect(() => {
document.addEventListener('tick', updateLastLoadTimestamp)

return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [])

useMountedEffect(() => {
api.cancelAll()
setQuery(parseQuery(location.search, site))
updateLastLoadTimestamp()
}, [location.search])

return (
<WrappedComponent
{...props}
query={query}
importedDataInView={importedDataInView}
updateImportedDataInView={setImportedDataInView}
lastLoadTimestamp={lastLoadTimestamp}
/>
)
}
}
14 changes: 13 additions & 1 deletion assets/js/dashboard/custom-hooks.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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])
}
39 changes: 13 additions & 26 deletions assets/js/dashboard/index.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import React, { useEffect, useState } from 'react';
import React from 'react'
import { withRouter } from 'react-router-dom'

import { useMountedEffect } from './custom-hooks';
import Historical from './historical'
import Realtime from './realtime'
import {parseQuery} from './query'
import * as api from './api'
import withQueryContext from './components/query-context-hoc';

export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded"

function Dashboard(props) {
const { location, site, loggedIn, currentUserRole } = props
const [query, setQuery] = useState(parseQuery(location.search, site))
const [importedDataInView, setImportedDataInView] = useState(false)
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
const updateLastLoadTimestamp = () => { setLastLoadTimestamp(new Date()) }

useEffect(() => {
document.addEventListener('tick', updateLastLoadTimestamp)

return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [])

useMountedEffect(() => {
api.cancelAll()
setQuery(parseQuery(location.search, site))
updateLastLoadTimestamp()
}, [location.search])

const {
site,
loggedIn,
currentUserRole,
query,
importedDataInView,
updateImportedDataInView,
lastLoadTimestamp
} = props

if (query.period === 'realtime') {
return (
Expand All @@ -50,10 +37,10 @@ function Dashboard(props) {
query={query}
lastLoadTimestamp={lastLoadTimestamp}
importedDataInView={importedDataInView}
updateImportedDataInView={setImportedDataInView}
updateImportedDataInView={updateImportedDataInView}
/>
)
}
}

export default withRouter(Dashboard)
export default withRouter(withQueryContext(Dashboard))
25 changes: 25 additions & 0 deletions assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +49,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
Expand Down Expand Up @@ -134,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

Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/realtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function Realtime(props) {
<Datepicker site={site} query={query} />
</div>
</div>
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp} />
<VisitorGraph site={site} query={query} lastLoadTimestamp={lastLoadTimestamp}/>
<div className="w-full md:flex">
<div className={ statsBoxClass }>
<Sources site={site} query={query} />
Expand Down
25 changes: 3 additions & 22 deletions assets/js/dashboard/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -51,14 +50,8 @@ export default function Router({ site, loggedIn, currentUserRole }) {
<Route path="/exit-pages">
<ExitPagesModal site={site} />
</Route>
<Route path="/countries">
<ModalTable title="Top countries" site={site} endpoint={url.apiPath(site, '/countries')} filterKey="country" keyLabel="Country" renderIcon={renderCountryIcon} showPercentage={true} />
</Route>
<Route path="/regions">
<ModalTable title="Top regions" site={site} endpoint={url.apiPath(site, '/regions')} filterKey="region" keyLabel="Region" renderIcon={renderRegionIcon} />
</Route>
<Route path="/cities">
<ModalTable title="Top cities" site={site} endpoint={url.apiPath(site, '/cities')} filterKey="city" keyLabel="City" renderIcon={renderCityIcon} />
<Route exact path={["/countries", "/regions", "/cities"]}>
<LocationsModal site={site} />
</Route>
<Route path="/custom-prop-values/:prop_key">
<PropsModal site={site} />
Expand All @@ -74,15 +67,3 @@ export default function Router({ site, loggedIn, currentUserRole }) {
</BrowserRouter >
);
}

function renderCityIcon(city) {
return <span className="mr-1">{city.country_flag}</span>
}

function renderCountryIcon(country) {
return <span className="mr-1">{country.flag}</span>
}

function renderRegionIcon(region) {
return <span className="mr-1">{region.country_flag}</span>
}
20 changes: 12 additions & 8 deletions assets/js/dashboard/stats/behaviours/conversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -19,6 +19,16 @@ export default function Conversions(props) {
}
}

function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Total", meta: {hiddenOnMobile: true}}),
metrics.createConversionRate(),
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
].filter(metric => !!metric)
}

/*global BUILD_EXTRA*/
return (
<ListReport
Expand All @@ -27,13 +37,7 @@ export default function Conversions(props) {
getFilterFor={getFilterFor}
keyLabel="Goal"
onClick={props.onGoalFilterClick}
metrics={[
{ name: 'visitors', label: "Uniques", plot: true },
{ name: 'events', label: "Total", hiddenOnMobile: true },
CR_METRIC,
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath('conversions')}
maybeHideDetails={true}
query={query}
Expand Down
16 changes: 10 additions & 6 deletions assets/js/dashboard/stats/behaviours/goal-conversions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react"
import Conversions from './conversions'
import ListReport from "../reports/list"
import { CR_METRIC } from "../reports/metrics"
import * as metrics from '../reports/metrics'
import * as url from "../../util/url"
import * as api from "../../api"
import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters"
Expand Down Expand Up @@ -53,17 +53,21 @@ function SpecialPropBreakdown(props) {
}
}

function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
metrics.createConversionRate()
].filter(metric => !!metric)
}

return (
<ListReport
fetchData={fetchData}
afterFetchData={afterFetchData}
getFilterFor={getFilterFor}
keyLabel={prop}
metrics={[
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
CR_METRIC
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
externalLinkDest={externalLinkDest()}
maybeHideDetails={true}
Expand Down
23 changes: 14 additions & 9 deletions assets/js/dashboard/stats/behaviours/props.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useCallback, useEffect, useState } from "react"
import ListReport, { MIN_HEIGHT } from "../reports/list";
import Combobox from '../../components/combobox'
import * as metrics from '../reports/metrics'
import * as api from '../../api'
import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage";
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } from "../../util/filters"
import classNames from "classnames";
Expand Down Expand Up @@ -82,22 +82,27 @@ export default function Properties(props) {
setPropKey(newPropKey)
}
}

/*global BUILD_EXTRA*/
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: {plot: true}}),
metrics.createEvents({renderLabel: (_query) => "Events", meta: {hiddenOnMobile: true}}),
hasGoalFilter(query) && metrics.createConversionRate(),
!hasGoalFilter(query) && metrics.createPercentage(),
BUILD_EXTRA && metrics.createTotalRevenue({meta: {hiddenOnMobile: true}}),
BUILD_EXTRA && metrics.createAverageRevenue({meta: {hiddenOnMobile: true}})
].filter(metric => !!metric)
}

function renderBreakdown() {
return (
<ListReport
fetchData={fetchProps}
afterFetchData={props.afterFetchData}
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={[
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
hasGoalFilter(query) ? CR_METRIC : PERCENTAGE_METRIC,
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
maybeHideDetails={true}
query={query}
Expand Down
Loading