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 (
+ 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)