analytics/assets/js/dashboard/query.js

200 lines
6.3 KiB
JavaScript

import React, {useCallback} from 'react'
import { parseSearch, stringifySearch } from './util/url'
import { AppNavigationLink, useAppNavigate } from './navigation/use-app-navigate'
import { nowForSite } from './util/date'
import * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
import { getFiltersByKeyPrefix, parseLegacyFilter, parseLegacyPropsFilter } from './util/filters'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useQueryContext } from './query-context'
dayjs.extend(utc)
const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '6mo', '12mo', 'year', 'all', 'custom']
export function parseQuery(searchRecord, site) {
const getValue = (k) => searchRecord[k];
let period = getValue('period')
const periodKey = `period__${site.domain}`
if (PERIODS.includes(period)) {
if (period !== 'custom' && period !== 'realtime') {storage.setItem(periodKey, period)}
} else if (storage.getItem(periodKey)) {
period = storage.getItem(periodKey)
} else {
period = '30d'
}
let comparison = getValue('comparison') ?? getStoredComparisonMode(site.domain, null)
if (COMPARISON_DISABLED_PERIODS.includes(period) || !isComparisonEnabled(comparison)) comparison = null
let matchDayOfWeek = getValue('match_day_of_week') ?? getStoredMatchDayOfWeek(site.domain, true)
return {
period,
comparison,
compare_from: getValue('compare_from') ? dayjs.utc(getValue('compare_from')) : undefined,
compare_to: getValue('compare_to') ? dayjs.utc(getValue('compare_to')) : undefined,
date: getValue('date') ? dayjs.utc(getValue('date')) : nowForSite(site),
from: getValue('from') ? dayjs.utc(getValue('from')) : undefined,
to: getValue('to') ? dayjs.utc(getValue('to')) : undefined,
match_day_of_week: matchDayOfWeek === true,
with_imported: getValue('with_imported') ?? true,
filters: getValue('filters') || [],
labels: getValue('labels') || {}
}
}
export function addFilter(query, filter) {
return { ...query, filters: [...query.filters, filter] }
}
export function navigateToQuery(navigate, {period}, newPartialSearchRecord) {
// if we update any data that we store in localstorage, make sure going back in history will
// revert them
if (newPartialSearchRecord.period && newPartialSearchRecord.period !== period) {
navigate({ search: (search) => ({ ...search, period: period }), replace: true })
}
// then push the new query to the history
navigate({ search: (search) => ({ ...search, ...newPartialSearchRecord }) })
}
const LEGACY_URL_PARAMETERS = {
'goal': null,
'source': null,
'utm_medium': null,
'utm_source': null,
'utm_campaign': null,
'utm_content': null,
'utm_term': null,
'referrer': null,
'screen': null,
'browser': null,
'browser_version': null,
'os': null,
'os_version': null,
'country': 'country_labels',
'region': 'region_labels',
'city': 'city_labels',
'page': null,
'hostname': null,
'entry_page': null,
'exit_page': null,
}
// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
// updates the filters and updates location
export function filtersBackwardsCompatibilityRedirect(windowLocation, windowHistory) {
const searchRecord = parseSearch(windowLocation.search)
const getValue = (k) => searchRecord[k];
// New filters are used - no need to do anything
if (getValue("filters")) {
return
}
const changedSearchRecordEntries = [];
let filters = []
let labels = {}
for (const [key, value] of Object.entries(searchRecord)) {
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
const filter = parseLegacyFilter(key, value)
filters.push(filter)
const labelsKey = LEGACY_URL_PARAMETERS[key]
if (labelsKey && getValue(labelsKey)) {
const clauses = filter[2]
const labelsValues = getValue(labelsKey).split('|').filter(label => !!label)
const newLabels = Object.fromEntries(clauses.map((clause, index) => [clause, labelsValues[index]]))
labels = Object.assign(labels, newLabels)
}
} else {
changedSearchRecordEntries.push([key, value])
}
}
if (getValue('props')) {
filters.push(...parseLegacyPropsFilter(getValue('props')))
}
if (filters.length > 0) {
changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
windowHistory.pushState({}, null, `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`)
}
}
// 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
}
export function QueryLink({ to, search, className, children, onClick }) {
const navigate = useAppNavigate();
const { query } = useQueryContext();
const handleClick = useCallback((e) => {
e.preventDefault()
navigateToQuery(navigate, query, search)
if (onClick) {
onClick(e)
}
}, [navigate, onClick, query, search])
return (
<AppNavigationLink
to={to}
search={(currentSearch) => ({...currentSearch, ...search})}
className={className}
onClick={handleClick}
>
{children}
</AppNavigationLink>
)
}
export function QueryButton({ search, disabled, className, children, onClick }) {
const navigate = useAppNavigate();
const { query } = useQueryContext();
const handleClick = useCallback((e) => {
e.preventDefault()
navigateToQuery(navigate, query, search)
if (onClick) {
onClick(e)
}
}, [navigate, onClick, query, search])
return (
<button
className={className}
onClick={handleClick}
type="button"
disabled={disabled}
>
{children}
</button>
)
}