diff --git a/assets/js/dashboard/components/combobox.js b/assets/js/dashboard/components/combobox.js
index 95a814e7798c..2a194185a305 100644
--- a/assets/js/dashboard/components/combobox.js
+++ b/assets/js/dashboard/components/combobox.js
@@ -1,14 +1,25 @@
-import React, { Fragment, useState, useCallback, useEffect, useRef } from 'react'
+/** @format */
+
+import React, {
+ Fragment,
+ useState,
+ useCallback,
+ useEffect,
+ useRef
+} from 'react'
import { Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { useMountedEffect, useDebounce } from '../custom-hooks'
-function Option({isHighlighted, onClick, onMouseEnter, text, id}) {
- const className = classNames('relative cursor-pointer select-none py-2 px-3', {
- 'text-gray-900 dark:text-gray-300': !isHighlighted,
- 'bg-indigo-600 text-white': isHighlighted,
- })
+function Option({ isHighlighted, onClick, onMouseEnter, text, id }) {
+ const className = classNames(
+ 'relative cursor-pointer select-none py-2 px-3',
+ {
+ 'text-gray-900 dark:text-gray-300': !isHighlighted,
+ 'bg-indigo-600 text-white': isHighlighted
+ }
+ )
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
@@ -27,7 +38,7 @@ function scrollTo(wrapper, id) {
const el = wrapper.querySelector('#' + id)
if (el) {
- el.scrollIntoView({block: 'center'})
+ el.scrollIntoView({ block: 'center' })
}
}
}
@@ -48,7 +59,7 @@ export default function PlausibleCombobox({
placeholder,
forceLoading,
className,
- boxClass,
+ boxClass
}) {
const isEmpty = values.length === 0
const [options, setOptions] = useState([])
@@ -63,8 +74,12 @@ export default function PlausibleCombobox({
const loading = isLoading || !!forceLoading
const visibleOptions = [...options]
- if (freeChoice && search.length > 0 && options.every(option => option.value !== search)) {
- visibleOptions.push({value: search, label: search, freeChoice: true})
+ if (
+ freeChoice &&
+ search.length > 0 &&
+ options.every((option) => option.value !== search)
+ ) {
+ visibleOptions.push({ value: search, label: search, freeChoice: true })
}
const afterFetchOptions = useCallback((loadedOptions) => {
@@ -74,6 +89,7 @@ export default function PlausibleCombobox({
}, [])
const initialFetchOptions = useCallback(() => {
+ setLoading(true)
fetchOptions('').then(afterFetchOptions)
}, [fetchOptions, afterFetchOptions])
@@ -87,7 +103,9 @@ export default function PlausibleCombobox({
const debouncedSearchOptions = useDebounce(searchOptions)
useEffect(() => {
- if (isOpen) { initialFetchOptions() }
+ if (isOpen) {
+ initialFetchOptions()
+ }
}, [isOpen, initialFetchOptions])
useMountedEffect(() => {
@@ -136,13 +154,19 @@ export default function PlausibleCombobox({
}
function isOptionDisabled(option) {
- const optionAlreadySelected = values.some((val) => val.value === option.value)
- const optionDisabled = (disabledOptions || []).some((val) => val?.value === option.value)
+ const optionAlreadySelected = values.some(
+ (val) => val.value === option.value
+ )
+ const optionDisabled = (disabledOptions || []).some(
+ (val) => val?.value === option.value
+ )
return optionAlreadySelected || optionDisabled
}
function onInput(e) {
- if (!isOpen) { setOpen(true) }
+ if (!isOpen) {
+ setOpen(true)
+ }
setSearch(e.target.value)
}
@@ -177,15 +201,19 @@ export default function PlausibleCombobox({
}
const handleClick = useCallback((e) => {
- if (containerRef.current && containerRef.current.contains(e.target)) { return }
+ if (containerRef.current && containerRef.current.contains(e.target)) {
+ return
+ }
setSearch('')
setOpen(false)
}, [])
useEffect(() => {
- document.addEventListener("mousedown", handleClick, false)
- return () => { document.removeEventListener("mousedown", handleClick, false) }
+ document.addEventListener('mousedown', handleClick, false)
+ return () => {
+ document.removeEventListener('mousedown', handleClick, false)
+ }
}, [handleClick])
useEffect(() => {
@@ -194,7 +222,8 @@ export default function PlausibleCombobox({
}
}, [isEmpty, singleOption, autoFocus])
- const searchBoxClass = 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'
+ const searchBoxClass =
+ 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'
const containerClass = classNames('relative w-full', {
[className]: !!className,
@@ -205,17 +234,17 @@ export default function PlausibleCombobox({
const itemSelected = values.length === 1
return (
-
- { itemSelected && renderSingleSelectedItem() }
+
+ {itemSelected && renderSingleSelectedItem()}
-
+ onChange={onInput}
+ >
)
}
@@ -233,32 +262,55 @@ export default function PlausibleCombobox({
function renderMultiOptionContent() {
return (
<>
- { values.map((value) => {
- return (
-
- {value.label}
- removeOption(value, e)} className="cursor-pointer font-bold ml-1">×
-
- )
- })
- }
-
+ {values.map((value) => {
+ return (
+
+ {value.label}
+ removeOption(value, e)}
+ className="cursor-pointer font-bold ml-1"
+ >
+ ×
+
+
+ )
+ })}
+
>
)
}
function renderDropDownContent() {
- const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isOptionDisabled(option))
+ const matchesFound =
+ visibleOptions.length > 0 &&
+ visibleOptions.some((option) => !isOptionDisabled(option))
if (loading) {
- return
Loading options...
+ return (
+
+ Loading options...
+
+ )
}
if (matchesFound) {
return visibleOptions
- .filter(option => !isOptionDisabled(option))
+ .filter((option) => !isOptionDisabled(option))
.map((option, i) => {
- const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
+ const text = option.freeChoice
+ ? `Filter by '${option.label}'`
+ : option.label
return (
Start typing to apply filter
+ return (
+
+ Start typing to apply filter
+
+ )
}
return (
- No matches found in the current dashboard. Try selecting a different time range or searching for something different
+ No matches found in the current dashboard. Try selecting a different
+ time range or searching for something different
)
}
- const defaultBoxClass = 'pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500'
+ const defaultBoxClass =
+ 'pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500'
const finalBoxClass = classNames(boxClass || defaultBoxClass, {
- 'border-indigo-500 ring-1 ring-indigo-500': isOpen,
+ 'border-indigo-500 ring-1 ring-indigo-500': isOpen
})
return (
-
+
{singleOption && renderSingleOptionContent()}
{!singleOption && renderMultiOptionContent()}
@@ -299,26 +357,47 @@ export default function PlausibleCombobox({
{loading && }
- {isOpen &&
-
- { renderDropDownContent() }
-
- }
+ {isOpen && (
+
+
+ {renderDropDownContent()}
+
+
+ )}
)
}
function Spinner() {
return (
-
-
-
+
+
+
)
}
diff --git a/assets/js/dashboard/components/filter-operator-selector.js b/assets/js/dashboard/components/filter-operator-selector.js
index 155e0164bcf5..32a18521473f 100644
--- a/assets/js/dashboard/components/filter-operator-selector.js
+++ b/assets/js/dashboard/components/filter-operator-selector.js
@@ -1,9 +1,16 @@
-import React, { Fragment } from "react";
+/** @format */
-import { FILTER_OPERATIONS, FILTER_OPERATIONS_DISPLAY_NAMES, isFreeChoiceFilter, supportsIsNot } from "../util/filters";
-import { Menu, Transition } from "@headlessui/react";
+import React, { Fragment } from 'react'
+
+import {
+ FILTER_OPERATIONS,
+ FILTER_OPERATIONS_DISPLAY_NAMES,
+ supportsContains,
+ supportsIsNot
+} from '../util/filters'
+import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
-import classNames from "classnames";
+import classNames from 'classnames'
export default function FilterOperatorSelector(props) {
const filterName = props.forFilter
@@ -15,11 +22,11 @@ export default function FilterOperatorSelector(props) {
{({ active }) => (
props.onSelect(operation)}
- className={classNames("cursor-pointer block px-4 py-2 text-sm", {
- "bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100": active,
- "text-gray-700 dark:text-gray-200": !active
- }
- )}
+ className={classNames('cursor-pointer block px-4 py-2 text-sm', {
+ 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100':
+ active,
+ 'text-gray-700 dark:text-gray-200': !active
+ })}
>
{FILTER_OPERATIONS_DISPLAY_NAMES[operation]}
@@ -29,7 +36,7 @@ export default function FilterOperatorSelector(props) {
)
}
- const containerClass = classNames("w-full", {
+ const containerClass = classNames('w-full', {
'opacity-20 cursor-default pointer-events-none': props.isDisabled
})
@@ -41,7 +48,10 @@ export default function FilterOperatorSelector(props) {
{FILTER_OPERATIONS_DISPLAY_NAMES[props.selectedType]}
-
+
@@ -61,9 +71,18 @@ export default function FilterOperatorSelector(props) {
>
{renderTypeItem(FILTER_OPERATIONS.is, true)}
- {renderTypeItem(FILTER_OPERATIONS.isNot, supportsIsNot(filterName))}
- {renderTypeItem(FILTER_OPERATIONS.contains, isFreeChoiceFilter(filterName))}
- {renderTypeItem(FILTER_OPERATIONS.contains_not, isFreeChoiceFilter(filterName) && supportsIsNot(filterName))}
+ {renderTypeItem(
+ FILTER_OPERATIONS.isNot,
+ supportsIsNot(filterName)
+ )}
+ {renderTypeItem(
+ FILTER_OPERATIONS.contains,
+ supportsContains(filterName)
+ )}
+ {renderTypeItem(
+ FILTER_OPERATIONS.contains_not,
+ supportsContains(filterName) && supportsIsNot(filterName)
+ )}
diff --git a/assets/js/dashboard/last-load-context.tsx b/assets/js/dashboard/last-load-context.tsx
new file mode 100644
index 000000000000..65bf9595a325
--- /dev/null
+++ b/assets/js/dashboard/last-load-context.tsx
@@ -0,0 +1,46 @@
+/* @format */
+import React, {
+ createContext,
+ useEffect,
+ useContext,
+ useState,
+ useCallback,
+ ReactNode
+} from 'react'
+import { useMountedEffect } from './custom-hooks'
+
+const LastLoadContext = createContext(new Date())
+
+export const useLastLoadContext = () => {
+ return useContext(LastLoadContext)
+}
+
+export default function LastLoadContextProvider({
+ children
+}: {
+ children: ReactNode
+}) {
+ const [timestamp, setTimestamp] = useState(new Date())
+
+ const updateTimestamp = useCallback(() => {
+ setTimestamp(new Date())
+ }, [setTimestamp])
+
+ useEffect(() => {
+ document.addEventListener('tick', updateTimestamp)
+
+ return () => {
+ document.removeEventListener('tick', updateTimestamp)
+ }
+ }, [updateTimestamp])
+
+ useMountedEffect(() => {
+ updateTimestamp()
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/assets/js/dashboard/query-context.tsx b/assets/js/dashboard/query-context.tsx
index 498f8b9d2be1..a523125a42c7 100644
--- a/assets/js/dashboard/query-context.tsx
+++ b/assets/js/dashboard/query-context.tsx
@@ -1,13 +1,5 @@
/* @format */
-import React, {
- createContext,
- useMemo,
- useEffect,
- useContext,
- useState,
- useCallback,
- ReactNode
-} from 'react'
+import React, { createContext, useMemo, useContext, ReactNode } from 'react'
import { useLocation } from 'react-router'
import { useMountedEffect } from './custom-hooks'
import * as api from './api'
@@ -30,8 +22,7 @@ import {
const queryContextDefaultValue = {
query: queryDefaultValue,
- otherSearch: {} as Record,
- lastLoadTimestamp: new Date()
+ otherSearch: {} as Record
}
export type QueryContextValue = typeof queryContextDefaultValue
@@ -130,26 +121,12 @@ export default function QueryContextProvider({
match_day_of_week
})
- const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
- const updateLastLoadTimestamp = useCallback(() => {
- setLastLoadTimestamp(new Date())
- }, [setLastLoadTimestamp])
-
- useEffect(() => {
- document.addEventListener('tick', updateLastLoadTimestamp)
-
- return () => {
- document.removeEventListener('tick', updateLastLoadTimestamp)
- }
- }, [updateLastLoadTimestamp])
-
useMountedEffect(() => {
api.cancelAll()
- updateLastLoadTimestamp()
}, [])
return (
-
+
{children}
)
diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx
index c3cfdb5de713..a58028b27ea9 100644
--- a/assets/js/dashboard/router.tsx
+++ b/assets/js/dashboard/router.tsx
@@ -26,6 +26,7 @@ import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
import QueryContextProvider from './query-context'
import { DashboardKeybinds } from './dashboard-keybinds'
+import LastLoadContextProvider from './last-load-context'
const queryClient = new QueryClient({
defaultOptions: {
@@ -39,8 +40,10 @@ function DashboardElement() {
return (
-
- {/** render any children of the root route below */}
+
+
+ {/** render any children of the root route below */}
+
diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js
index c86daebb94e9..5be2652fa716 100644
--- a/assets/js/dashboard/stats/current-visitors.js
+++ b/assets/js/dashboard/stats/current-visitors.js
@@ -1,18 +1,23 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import { AppNavigationLink } from '../navigation/use-app-navigate';
+/** @format */
+
+import React, { useCallback, useEffect, useState } from 'react'
+import { AppNavigationLink } from '../navigation/use-app-navigate'
import * as api from '../api'
-import { Tooltip } from '../util/tooltip';
-import { SecondsSinceLastLoad } from '../util/seconds-since-last-load';
-import { useQueryContext } from '../query-context';
-import { useSiteContext } from '../site-context';
+import { Tooltip } from '../util/tooltip'
+import { SecondsSinceLastLoad } from '../util/seconds-since-last-load'
+import { useQueryContext } from '../query-context'
+import { useSiteContext } from '../site-context'
+import { useLastLoadContext } from '../last-load-context'
export default function CurrentVisitors({ tooltipBoundary }) {
- const { query, lastLoadTimestamp } = useQueryContext();
- const site = useSiteContext();
+ const { query } = useQueryContext()
+ const lastLoadTimestamp = useLastLoadContext()
+ const site = useSiteContext()
const [currentVisitors, setCurrentVisitors] = useState(null)
const updateCount = useCallback(() => {
- api.get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`)
+ api
+ .get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`)
.then((res) => setCurrentVisitors(res))
}, [site.domain])
@@ -31,8 +36,13 @@ export default function CurrentVisitors({ tooltipBoundary }) {
function tooltipInfo() {
return (
-
Last updated s ago
-
Click to view realtime dashboard
+
+ Last updated{' '}
+ s ago
+
+
+ Click to view realtime dashboard
+
)
}
@@ -40,11 +50,21 @@ export default function CurrentVisitors({ tooltipBoundary }) {
if (currentVisitors !== null && query.filters.length === 0) {
return (
- ({ ...prev, period: 'realtime' })} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
-
+ ({ ...prev, period: 'realtime' })}
+ className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300"
+ >
+
- {currentVisitors} current visitor{currentVisitors === 1 ? '' : 's'}
+ {currentVisitors}{' '}
+
+ current visitor{currentVisitors === 1 ? '' : 's'}
+
)
diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js
index 53faaa8c7221..24d544b374be 100644
--- a/assets/js/dashboard/stats/graph/top-stats.js
+++ b/assets/js/dashboard/stats/graph/top-stats.js
@@ -1,13 +1,16 @@
-import React from "react";
-import { Tooltip } from '../../util/tooltip';
-import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load';
-import classNames from "classnames";
-import numberFormatter, { durationFormatter } from '../../util/number-formatter';
-import * as storage from '../../util/storage';
-import { formatDateRange } from '../../util/date';
-import { getGraphableMetrics } from "./graph-util";
-import { useQueryContext } from "../../query-context";
-import { useSiteContext } from "../../site-context";
+/** @format */
+
+import React from 'react'
+import { Tooltip } from '../../util/tooltip'
+import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
+import classNames from 'classnames'
+import numberFormatter, { durationFormatter } from '../../util/number-formatter'
+import * as storage from '../../util/storage'
+import { formatDateRange } from '../../util/date'
+import { getGraphableMetrics } from './graph-util'
+import { useQueryContext } from '../../query-context'
+import { useSiteContext } from '../../site-context'
+import { useLastLoadContext } from '../../last-load-context'
function Maybe({ condition, children }) {
if (condition) {
@@ -21,21 +24,31 @@ function renderPercentageComparison(name, comparison, forceDarkBg = false) {
const formattedComparison = numberFormatter(Math.abs(comparison))
const defaultClassName = classNames({
- "pl-2 text-xs dark:text-gray-100": !forceDarkBg,
- "pl-2 text-xs text-gray-100": forceDarkBg
+ 'pl-2 text-xs dark:text-gray-100': !forceDarkBg,
+ 'pl-2 text-xs text-gray-100': forceDarkBg
})
const noChangeClassName = classNames({
- "pl-2 text-xs text-gray-700 dark:text-gray-300": !forceDarkBg,
- "pl-2 text-xs text-gray-300": forceDarkBg
+ 'pl-2 text-xs text-gray-700 dark:text-gray-300': !forceDarkBg,
+ 'pl-2 text-xs text-gray-300': forceDarkBg
})
if (comparison > 0) {
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
- return ↑ {formattedComparison}%
+ return (
+
+ ↑ {' '}
+ {formattedComparison}%
+
+ )
} else if (comparison < 0) {
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
- return ↓ {formattedComparison}%
+ return (
+
+ ↓ {' '}
+ {formattedComparison}%
+
+ )
} else if (comparison === 0) {
return 〰 0%
} else {
@@ -48,7 +61,9 @@ function topStatNumberShort(name, value) {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
- } else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
+ } else if (
+ ['average revenue', 'total revenue'].includes(name.toLowerCase())
+ ) {
return value?.short
} else {
return numberFormatter(value)
@@ -60,7 +75,9 @@ function topStatNumberLong(name, value) {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
- } else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
+ } else if (
+ ['average revenue', 'total revenue'].includes(name.toLowerCase())
+ ) {
return value?.long
} else {
return (value || 0).toLocaleString()
@@ -68,8 +85,9 @@ function topStatNumberLong(name, value) {
}
export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
- const { query, lastLoadTimestamp } = useQueryContext();
- const site = useSiteContext();
+ const { query } = useQueryContext()
+ const lastLoadTimestamp = useLastLoadContext()
+ const site = useSiteContext()
function tooltip(stat) {
let statName = stat.name.toLowerCase()
@@ -77,16 +95,28 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
return (
- {query.comparison &&
- {topStatNumberLong(stat.name, stat.value)} vs. {topStatNumberLong(stat.name, stat.comparison_value)} {statName}
- {renderPercentageComparison(stat.name, stat.change, true)}
-
}
-
- {!query.comparison &&
- {topStatNumberLong(stat.name, stat.value)} {statName}
-
}
+ {query.comparison && (
+
+ {topStatNumberLong(stat.name, stat.value)} vs.{' '}
+ {topStatNumberLong(stat.name, stat.comparison_value)} {statName}
+
+ {renderPercentageComparison(stat.name, stat.change, true)}
+
+
+ )}
- {stat.name === 'Current visitors' &&
Last updated s ago
}
+ {!query.comparison && (
+
+ {topStatNumberLong(stat.name, stat.value)} {statName}
+
+ )}
+
+ {stat.name === 'Current visitors' && (
+
+ Last updated{' '}
+ s ago
+
+ )}
)
}
@@ -105,7 +135,11 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
function blinkingDot() {
return (
-
+
)
}
@@ -118,46 +152,75 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
- const statDisplayNameClass = classNames('text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b', {
- 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500': isSelected,
- 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent': !isSelected
- })
+ const statDisplayNameClass = classNames(
+ 'text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b',
+ {
+ 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500':
+ isSelected,
+ 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent':
+ !isSelected
+ }
+ )
return (
{statDisplayName}
- {statExtraName && {statExtraName} }
+ {statExtraName && (
+ {statExtraName}
+ )}
)
}
function renderStat(stat, index) {
- const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
- 'cursor-pointer': canMetricBeGraphed(stat),
- 'lg:border-l border-gray-300': index > 0,
- 'border-r lg:border-r-0': index % 2 === 0
- })
+ const className = classNames(
+ 'px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none',
+ {
+ 'cursor-pointer': canMetricBeGraphed(stat),
+ 'lg:border-l border-gray-300': index > 0,
+ 'border-r lg:border-r-0': index % 2 === 0
+ }
+ )
return (
- { maybeUpdateMetric(stat) }} boundary={tooltipBoundary}>
+ {
+ maybeUpdateMetric(stat)
+ }}
+ boundary={tooltipBoundary}
+ >
{renderStatName(stat)}
- {topStatNumberShort(stat.name, stat.value)}
+
+ {topStatNumberShort(stat.name, stat.value)}
+
{renderPercentageComparison(stat.name, stat.change)}
- {formatDateRange(site, data.from, data.to)}
+
+ {formatDateRange(site, data.from, data.to)}
+
-
{topStatNumberShort(stat.name, stat.comparison_value)}
-
{formatDateRange(site, data.comparing_from, data.comparing_to)}
+
+ {topStatNumberShort(stat.name, stat.comparison_value)}
+
+
+ {formatDateRange(site, data.comparing_from, data.comparing_to)}
+
@@ -171,5 +234,5 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
stats.push(blinkingDot())
}
- return stats || null;
+ return stats || null
}
diff --git a/assets/js/dashboard/stats/modals/filter-modal-props-row.js b/assets/js/dashboard/stats/modals/filter-modal-props-row.js
index d8fd123ba819..7de1553646e1 100644
--- a/assets/js/dashboard/stats/modals/filter-modal-props-row.js
+++ b/assets/js/dashboard/stats/modals/filter-modal-props-row.js
@@ -1,20 +1,28 @@
-import React, { useMemo } from "react"
+/** @format */
+
+import React, { useMemo } from 'react'
import { TrashIcon } from '@heroicons/react/20/solid'
-import FilterOperatorSelector from "../../components/filter-operator-selector"
+import FilterOperatorSelector from '../../components/filter-operator-selector'
import Combobox from '../../components/combobox'
import { apiPath } from '../../util/url'
-import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS, fetchSuggestions, getPropertyKeyFromFilterKey } from '../../util/filters'
-import { useQueryContext } from "../../query-context"
-import { useSiteContext } from "../../site-context"
+import {
+ EVENT_PROPS_PREFIX,
+ FILTER_OPERATIONS,
+ fetchSuggestions,
+ getPropertyKeyFromFilterKey,
+ isFreeChoiceFilterOperation
+} from '../../util/filters'
+import { useQueryContext } from '../../query-context'
+import { useSiteContext } from '../../site-context'
export default function FilterModalPropsRow({
filter,
showDelete,
disabledOptions,
onUpdate,
- onDelete,
+ onDelete
}) {
const { query } = useQueryContext()
const site = useSiteContext()
@@ -31,16 +39,28 @@ export default function FilterModalPropsRow({
)
function fetchPropKeyOptions(input) {
- return fetchSuggestions(apiPath(site, `/suggestions/prop_key`), query, input)
+ return fetchSuggestions(
+ apiPath(site, `/suggestions/prop_key`),
+ query,
+ input
+ )
}
function fetchPropValueOptions(input) {
- if ([FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(operation) || propKey == "") {
+ if (
+ [FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(
+ operation
+ ) ||
+ propKey == ''
+ ) {
return Promise.resolve([])
}
- return fetchSuggestions(apiPath(site, `/suggestions/prop_value`), query, input, [
- FILTER_OPERATIONS.isNot, filterKey, ['(none)']
- ])
+ return fetchSuggestions(
+ apiPath(site, `/suggestions/prop_value`),
+ query,
+ input,
+ [FILTER_OPERATIONS.isNot, filterKey, ['(none)']]
+ )
}
function onPropKeySelect(selection) {
@@ -72,7 +92,9 @@ export default function FilterModalPropsRow({
onUpdate([newOperation, filterKey, clauses])}
+ onSelect={(newOperation) =>
+ onUpdate([newOperation, filterKey, clauses])
+ }
selectedType={operation}
/>
@@ -83,13 +105,16 @@ export default function FilterModalPropsRow({
values={selectedClauses}
onSelect={onPropValueSelect}
placeholder={'Value'}
- freeChoice={operation == FILTER_OPERATIONS.contains}
+ freeChoice={isFreeChoiceFilterOperation(operation)}
/>
{showDelete && (
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
-
+
diff --git a/assets/js/dashboard/stats/modals/filter-modal-row.js b/assets/js/dashboard/stats/modals/filter-modal-row.js
index adcc2ad50df1..03b171022ae3 100644
--- a/assets/js/dashboard/stats/modals/filter-modal-row.js
+++ b/assets/js/dashboard/stats/modals/filter-modal-row.js
@@ -1,48 +1,66 @@
-import React, { useMemo } from "react"
+/** @format */
-import FilterOperatorSelector from "../../components/filter-operator-selector"
+import React, { useMemo } from 'react'
+
+import FilterOperatorSelector from '../../components/filter-operator-selector'
import Combobox from '../../components/combobox'
-import { FILTER_OPERATIONS, fetchSuggestions, isFreeChoiceFilter, getLabel, formattedFilters } from "../../util/filters"
+import {
+ FILTER_OPERATIONS,
+ fetchSuggestions,
+ isFreeChoiceFilterOperation,
+ getLabel,
+ formattedFilters
+} from '../../util/filters'
import { apiPath } from '../../util/url'
-import { useQueryContext } from "../../query-context"
-import { useSiteContext } from "../../site-context"
+import { useQueryContext } from '../../query-context'
+import { useSiteContext } from '../../site-context'
-export default function FilterModalRow({
- filter,
- labels,
- onUpdate
-}) {
- const { query } = useQueryContext();
- const site = useSiteContext();
+export default function FilterModalRow({ filter, labels, onUpdate }) {
+ const { query } = useQueryContext()
+ const site = useSiteContext()
const [operation, filterKey, clauses] = filter
const selectedClauses = useMemo(
- () => clauses.map((value) => ({ value, label: getLabel(labels, filterKey, value) })),
+ () =>
+ clauses.map((value) => ({
+ value,
+ label: getLabel(labels, filterKey, value)
+ })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[filter, labels]
)
function onComboboxSelect(selection) {
const newClauses = selection.map(({ value }) => value)
- const newLabels = Object.fromEntries(selection.map(({ label, value }) => [value, label]))
-
- onUpdate(
- [operation, filterKey, newClauses],
- newLabels
+ const newLabels = Object.fromEntries(
+ selection.map(({ label, value }) => [value, label])
)
+
+ onUpdate([operation, filterKey, newClauses], newLabels)
}
function fetchOptions(input) {
- if ([FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(operation)) {
+ if (
+ [FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(
+ operation
+ )
+ ) {
return Promise.resolve([])
}
let additionalFilter = null
- if (filterKey !== 'goal') { additionalFilter = [FILTER_OPERATIONS.isNot, filterKey, clauses] }
+ if (filterKey !== 'goal') {
+ additionalFilter = [FILTER_OPERATIONS.isNot, filterKey, clauses]
+ }
- return fetchSuggestions(apiPath(site, `/suggestions/${filterKey}`), query, input, additionalFilter)
+ return fetchSuggestions(
+ apiPath(site, `/suggestions/${filterKey}`),
+ query,
+ input,
+ additionalFilter
+ )
}
return (
@@ -50,14 +68,16 @@ export default function FilterModalRow({
onUpdate([newOperation, filterKey, clauses], labels)}
+ onSelect={(newOperation) =>
+ onUpdate([newOperation, filterKey, clauses], labels)
+ }
selectedType={operation}
/>
word.toLowerCase().startsWith(vowel))) {
+ }
+ if (
+ ['a', 'e', 'i', 'o', 'u'].some((vowel) =>
+ word.toLowerCase().startsWith(vowel)
+ )
+ ) {
return `an ${word}`
}
return `a ${word}`
diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js
index 31f96a0b196b..f1ad74f5e043 100644
--- a/assets/js/dashboard/util/filters.js
+++ b/assets/js/dashboard/util/filters.js
@@ -1,35 +1,37 @@
-import React, { useMemo } from "react"
+/** @format */
+
+import React, { useMemo } from 'react'
import * as api from '../api'
import { useQueryContext } from '../query-context'
export const FILTER_MODAL_TO_FILTER_GROUP = {
- 'page': ['page', 'entry_page', 'exit_page'],
- 'source': ['source', 'referrer'],
- 'location': ['country', 'region', 'city'],
- 'screen': ['screen'],
- 'browser': ['browser', 'browser_version'],
- 'os': ['os', 'os_version'],
- 'utm': ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
- 'goal': ['goal'],
- 'props': ['props'],
- 'hostname': ['hostname']
+ page: ['page', 'entry_page', 'exit_page'],
+ source: ['source', 'referrer'],
+ location: ['country', 'region', 'city'],
+ screen: ['screen'],
+ browser: ['browser', 'browser_version'],
+ os: ['os', 'os_version'],
+ utm: ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
+ goal: ['goal'],
+ props: ['props'],
+ hostname: ['hostname']
}
export const FILTER_GROUP_TO_MODAL_TYPE = Object.fromEntries(
- Object.entries(FILTER_MODAL_TO_FILTER_GROUP)
- .flatMap(([modalName, filterGroups]) => filterGroups.map((filterGroup) => [filterGroup, modalName]))
+ Object.entries(FILTER_MODAL_TO_FILTER_GROUP).flatMap(
+ ([modalName, filterGroups]) =>
+ filterGroups.map((filterGroup) => [filterGroup, modalName])
+ )
)
-export const NO_CONTAINS_OPERATOR = new Set(['screen'].concat(FILTER_MODAL_TO_FILTER_GROUP['location']))
-
-export const EVENT_PROPS_PREFIX = "props:"
+export const EVENT_PROPS_PREFIX = 'props:'
export const FILTER_OPERATIONS = {
is: 'is',
isNot: 'is_not',
contains: 'contains',
contains_not: 'contains_not'
-};
+}
export const FILTER_OPERATIONS_DISPLAY_NAMES = {
[FILTER_OPERATIONS.is]: 'is',
@@ -42,22 +44,29 @@ const OPERATION_PREFIX = {
[FILTER_OPERATIONS.isNot]: '!',
[FILTER_OPERATIONS.contains]: '~',
[FILTER_OPERATIONS.is]: ''
-};
-
+}
export function supportsIsNot(filterName) {
return !['goal', 'prop_key'].includes(filterName)
}
-export function isFreeChoiceFilter(filterName) {
- return !NO_CONTAINS_OPERATOR.has(filterName)
+export function supportsContains(filterName) {
+ return !['screen']
+ .concat(FILTER_MODAL_TO_FILTER_GROUP['location'])
+ .includes(filterName)
+}
+
+export function isFreeChoiceFilterOperation(operation) {
+ return [FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(
+ operation
+ )
}
// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
// escaping pipe characters in filters does not currently work in Safari
-let NON_ESCAPED_PIPE_REGEX;
+let NON_ESCAPED_PIPE_REGEX
try {
- NON_ESCAPED_PIPE_REGEX = new RegExp("(? filterKey.startsWith(prefix))
+ return query.filters.filter(([_operation, filterKey, _clauses]) =>
+ filterKey.startsWith(prefix)
+ )
}
function omitFiltersByKeyPrefix(query, prefix) {
- return query.filters.filter(([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix))
+ return query.filters.filter(
+ ([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix)
+ )
}
export function replaceFilterByPrefix(query, prefix, filter) {
@@ -92,18 +105,27 @@ export function isFilteringOnFixedValue(query, filterKey, expectedValue) {
const filters = query.filters.filter(([_operation, key]) => filterKey == key)
if (filters.length == 1) {
const [operation, _filterKey, clauses] = filters[0]
- return operation === FILTER_OPERATIONS.is && clauses.length === 1 && (!expectedValue || clauses[0] == expectedValue)
+ return (
+ operation === FILTER_OPERATIONS.is &&
+ clauses.length === 1 &&
+ (!expectedValue || clauses[0] == expectedValue)
+ )
}
return false
}
export function hasGoalFilter(query) {
- return getFiltersByKeyPrefix(query, "goal").length > 0
+ return getFiltersByKeyPrefix(query, 'goal').length > 0
}
export function useHasGoalFilter() {
- const { query: { filters } } = useQueryContext();
- return useMemo(() => getFiltersByKeyPrefix({ filters }, "goal").length > 0, [filters]);
+ const {
+ query: { filters }
+ } = useQueryContext()
+ return useMemo(
+ () => getFiltersByKeyPrefix({ filters }, 'goal').length > 0,
+ [filters]
+ )
}
export function isRealTimeDashboard(query) {
@@ -111,8 +133,10 @@ export function isRealTimeDashboard(query) {
}
export function useIsRealtimeDashboard() {
- const { query: { period } } = useQueryContext();
- return useMemo(() => isRealTimeDashboard({ period }), [period]);
+ const {
+ query: { period }
+ } = useQueryContext()
+ return useMemo(() => isRealTimeDashboard({ period }), [period])
}
export function plainFilterText(query, [operation, filterKey, clauses]) {
@@ -132,19 +156,34 @@ export function styledFilterText(query, [operation, filterKey, clauses]) {
const formattedFilter = formattedFilters[filterKey]
if (formattedFilter) {
- return <>{formattedFilter} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]} {clauses.map((value) => {getLabel(query.labels, filterKey, value)} ).reduce((prev, curr) => [prev, ' or ', curr])} >
+ return (
+ <>
+ {formattedFilter} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
+ {clauses
+ .map((value) => (
+ {getLabel(query.labels, filterKey, value)}
+ ))
+ .reduce((prev, curr) => [prev, ' or ', curr])}{' '}
+ >
+ )
} else if (filterKey.startsWith(EVENT_PROPS_PREFIX)) {
const propKey = getPropertyKeyFromFilterKey(filterKey)
- return <>Property {propKey} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]} {clauses.map((label) => {label} ).reduce((prev, curr) => [prev, ' or ', curr])} >
+ return (
+ <>
+ Property {propKey} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
+ {clauses
+ .map((label) => {label} )
+ .reduce((prev, curr) => [prev, ' or ', curr])}{' '}
+ >
+ )
}
throw new Error(`Unknown filter: ${filterKey}`)
}
-
// Note: Currently only a single goal filter can be applied at a time.
export function getGoalFilter(query) {
- return getFiltersByKeyPrefix(query, "goal")[0] || null
+ return getFiltersByKeyPrefix(query, 'goal')[0] || null
}
export function formatFilterGroup(filterGroup) {
@@ -162,7 +201,9 @@ export function formatFilterGroup(filterGroup) {
export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
const filteredBy = Object.fromEntries(
filters
- .flatMap(([_operation, filterKey, clauses]) => ['country', 'region', 'city'].includes(filterKey) ? clauses : [])
+ .flatMap(([_operation, filterKey, clauses]) =>
+ ['country', 'region', 'city'].includes(filterKey) ? clauses : []
+ )
.map((value) => [value, true])
)
let result = { ...labels }
@@ -172,7 +213,10 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
}
}
- if (mergedFilterKey && ['country', 'region', 'city'].includes(mergedFilterKey)) {
+ if (
+ mergedFilterKey &&
+ ['country', 'region', 'city'].includes(mergedFilterKey)
+ ) {
result = {
...result,
...mergedLabels
@@ -182,12 +226,15 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
return result
}
-const EVENT_FILTER_KEYS = new Set(["name", "page", "goal", "hostname"])
+const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])
export function serializeApiFilters(filters) {
const apiFilters = filters.map(([operation, filterKey, clauses]) => {
let apiFilterKey = `visit:${filterKey}`
- if (filterKey.startsWith(EVENT_PROPS_PREFIX) || EVENT_FILTER_KEYS.has(filterKey)) {
+ if (
+ filterKey.startsWith(EVENT_PROPS_PREFIX) ||
+ EVENT_FILTER_KEYS.has(filterKey)
+ ) {
apiFilterKey = `event:${filterKey}`
}
return [operation, apiFilterKey, clauses]
@@ -220,39 +267,40 @@ export function getFilterGroup([_operation, filterKey, _clauses]) {
return filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
}
-
export const formattedFilters = {
- 'goal': 'Goal',
- 'props': 'Property',
- 'prop_key': 'Property',
- 'prop_value': 'Value',
- 'source': 'Source',
- 'utm_medium': 'UTM Medium',
- 'utm_source': 'UTM Source',
- 'utm_campaign': 'UTM Campaign',
- 'utm_content': 'UTM Content',
- 'utm_term': 'UTM Term',
- 'referrer': 'Referrer URL',
- 'screen': 'Screen size',
- 'browser': 'Browser',
- 'browser_version': 'Browser Version',
- 'os': 'Operating System',
- 'os_version': 'Operating System Version',
- 'country': 'Country',
- 'region': 'Region',
- 'city': 'City',
- 'page': 'Page',
- 'hostname': 'Hostname',
- 'entry_page': 'Entry Page',
- 'exit_page': 'Exit Page',
+ goal: 'Goal',
+ props: 'Property',
+ prop_key: 'Property',
+ prop_value: 'Value',
+ source: 'Source',
+ utm_medium: 'UTM Medium',
+ utm_source: 'UTM Source',
+ utm_campaign: 'UTM Campaign',
+ utm_content: 'UTM Content',
+ utm_term: 'UTM Term',
+ referrer: 'Referrer URL',
+ screen: 'Screen size',
+ browser: 'Browser',
+ browser_version: 'Browser Version',
+ os: 'Operating System',
+ os_version: 'Operating System Version',
+ country: 'Country',
+ region: 'Region',
+ city: 'City',
+ page: 'Page',
+ hostname: 'Hostname',
+ entry_page: 'Entry Page',
+ exit_page: 'Exit Page'
}
-
export function parseLegacyFilter(filterKey, rawValue) {
- const operation = Object.keys(OPERATION_PREFIX)
- .find(operation => OPERATION_PREFIX[operation] === rawValue[0]) || FILTER_OPERATIONS.is;
+ const operation =
+ Object.keys(OPERATION_PREFIX).find(
+ (operation) => OPERATION_PREFIX[operation] === rawValue[0]
+ ) || FILTER_OPERATIONS.is
- const value = operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
+ const value =
+ operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
const clauses = value
.split(NON_ESCAPED_PIPE_REGEX)