mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 01:22:15 +03:00
Support multiple filters - frontend (#2773)
* Wrap Plausible.Stats.Filters with unit tests * Parse `member` filter type * Support for `member` filter in `aggregate_time_on_page` * Support `not_member` filter type * Support `matches_member` and `not_matches_member` filters * Extract util module for React filters * Implement Combobox from scratch with no libs * Support multple filter clauses in combobox * Don't use browser / os in version label * Show highlighted option in combobox * WIP * Fix location filters outside filter modal * Align open/close behaviour with react-select * Styling updates for combobox * Add support for wildcards in Combobox * Implement keybindings for combobox * Allow free choice inputs in combobox * Rename 'Save filter' -> Apply filter * Remove TODO comment * Clean up some rebase mistakes * Rename `allowWildcard` -> `freeChoice` * Dark mode fixes * Remove hint from filter modal * Escape pipe character in filter modal * Do not allow selecting duplicate options in combobox * Escape brackets in `page_regex/1` * Fix disabled style in dark mode * Add regex fallback for safari * Show no matches found when visibleOptions is empty * Disable enter key when no visible options * Do not submit empty form fields * Remove unnecessary setOpen(true)
This commit is contained in:
parent
d2f2c69387
commit
46048e50f7
@ -1,7 +1,218 @@
|
||||
import React, { Fragment, useState, useCallback } from 'react'
|
||||
import { Combobox, Transition } from '@headlessui/react'
|
||||
import React, { Fragment, useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import debounce from 'debounce-promise'
|
||||
import classNames from 'classnames'
|
||||
|
||||
function Option({isHighlighted, isDisabled, onClick, onMouseEnter, text, id}) {
|
||||
const className = classNames('relative select-none py-2 px-3', {
|
||||
'cursor-pointer': !isDisabled,
|
||||
'text-gray-300 dark:text-gray-600': isDisabled,
|
||||
'text-gray-900 dark:text-gray-300': !isDisabled && !isHighlighted,
|
||||
'bg-indigo-600 text-white': !isDisabled && isHighlighted,
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
id={id}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<span className="block truncate">{text}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
function scrollTo(wrapper, id) {
|
||||
if (wrapper) {
|
||||
const el = wrapper.querySelector('#' + id);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({block: 'center'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function optionId(index) {
|
||||
return `plausible-combobox-option-${index}`
|
||||
}
|
||||
|
||||
export default function PlausibleCombobox(props) {
|
||||
const [options, setOptions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const searchRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
const visibleOptions = [...options]
|
||||
if (props.freeChoice && input.length > 0 && options.every(option => option.value !== input)) {
|
||||
visibleOptions.push({value: input, label: input, freeChoice: true})
|
||||
}
|
||||
|
||||
function highLight(index) {
|
||||
let newIndex = index
|
||||
|
||||
if (index < 0) {
|
||||
newIndex = visibleOptions.length - 1
|
||||
} else if (index >= visibleOptions.length) {
|
||||
newIndex = 0
|
||||
}
|
||||
|
||||
setHighlightedIndex(newIndex)
|
||||
scrollTo(listRef.current, optionId(newIndex))
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (!isOpen || loading || visibleOptions.length === 0) return null
|
||||
selectOption(visibleOptions[highlightedIndex])
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (!isOpen || loading) return null
|
||||
setOpen(false)
|
||||
searchRef.current?.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (isOpen) {
|
||||
highLight(highlightedIndex + 1)
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (isOpen) {
|
||||
highLight(highlightedIndex - 1)
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabled(option) {
|
||||
return props.values.some((val) => val.value === option.value)
|
||||
}
|
||||
|
||||
function fetchOptions(query) {
|
||||
setLoading(true)
|
||||
|
||||
return props.fetchOptions(query).then((loadedOptions) => {
|
||||
setLoading(false)
|
||||
setHighlightedIndex(0)
|
||||
setOptions(loadedOptions)
|
||||
})
|
||||
}
|
||||
|
||||
const debouncedFetchOptions = useCallback(debounce(fetchOptions, 200), [])
|
||||
|
||||
function onInput(e) {
|
||||
const newInput = e.target.value
|
||||
setInput(newInput)
|
||||
debouncedFetchOptions(newInput)
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (!isOpen) {
|
||||
fetchOptions(input)
|
||||
searchRef.current.focus()
|
||||
setOpen(true)
|
||||
} else {
|
||||
setInput('')
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (isDisabled(option)) return
|
||||
|
||||
props.onChange([...props.values, option])
|
||||
setOpen(false)
|
||||
setInput('')
|
||||
searchRef.current.focus()
|
||||
}
|
||||
|
||||
function removeOption(option, e) {
|
||||
e.stopPropagation()
|
||||
const newValues = props.values.filter((val) => val.value !== option.value)
|
||||
props.onChange(newValues)
|
||||
searchRef.current.focus()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
if (containerRef.current && containerRef.current.contains(e.target)) return;
|
||||
|
||||
setInput('')
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", handleClick, false);
|
||||
return () => { document.removeEventListener("mousedown", handleClick, false); }
|
||||
}, [])
|
||||
|
||||
const matchesFound = !loading && visibleOptions.length > 0
|
||||
const noMatchesFound = !loading && visibleOptions.length === 0
|
||||
|
||||
return (
|
||||
<div onKeyDown={onKeyDown} ref={containerRef} className="relative ml-2 w-full">
|
||||
<div onClick={toggleOpen} className={classNames('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', {'border-indigo-500 ring-1 ring-indigo-500': isOpen, '': !isOpen})}>
|
||||
{ props.values.map((value) => {
|
||||
return (
|
||||
<div key={value.value} className="bg-indigo-100 dark:bg-indigo-600 flex justify-between w-full rounded-sm px-2 py-0.5 m-0.5 text-sm">{value.label} <span onClick={(e) => removeOption(value, e)} className="cursor-pointer font-bold ml-1">×</span></div>
|
||||
)
|
||||
})
|
||||
}
|
||||
<input className="border-none py-1 px-1 p-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm" ref={searchRef} value={input} style={{backgroundColor: "inherit"}} placeholder={props.placeholder} type="text" onChange={onInput}></input>
|
||||
<div className="cursor-pointer absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{!loading && <ChevronDownIcon className="h-4 w-4 text-gray-500" />}
|
||||
{loading && <Spinner />}
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={isOpen}
|
||||
>
|
||||
<ul ref={listRef} className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900">
|
||||
{ loading && (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
|
||||
Loading options...
|
||||
</div>
|
||||
)}
|
||||
{ noMatchesFound && (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
|
||||
No matches found in the current dashboard. Try selecting a different time range or searching for something different
|
||||
</div>
|
||||
)}
|
||||
{ matchesFound && (
|
||||
visibleOptions.map((option, i) => {
|
||||
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={option.value}
|
||||
id={optionId(i)}
|
||||
isHighlighted={highlightedIndex === i}
|
||||
isDisabled={isDisabled(option)}
|
||||
onClick={() => selectOption(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(i)}
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
@ -11,96 +222,3 @@ function Spinner() {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlausibleCombobox(props) {
|
||||
const [options, setOptions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function fetchOptions(query) {
|
||||
setLoading(true)
|
||||
|
||||
return props.fetchOptions(query).then((loadedOptions) => {
|
||||
setLoading(false)
|
||||
setOptions(loadedOptions)
|
||||
})
|
||||
}
|
||||
|
||||
const debouncedFetchOptions = useCallback(debounce(fetchOptions, 200), [])
|
||||
|
||||
function onOpen() {
|
||||
setOptions([])
|
||||
fetchOptions(props.selection.label)
|
||||
}
|
||||
|
||||
function onBlur(e) {
|
||||
!props.strict && props.onChange({
|
||||
value: e.target.value,
|
||||
label: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function renderOptions() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
|
||||
Loading options...
|
||||
</div>
|
||||
)
|
||||
} else if (!loading && options.length === 0) {
|
||||
return (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">
|
||||
No matches found in the current dashboard. Try selecting a different time range or searching for something different
|
||||
</div>
|
||||
)
|
||||
|
||||
} else {
|
||||
return options.map((option) => {
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 px-3 ${active ? 'bg-indigo-600 text-white' : 'text-gray-900 dark:text-gray-300'
|
||||
}`
|
||||
}
|
||||
value={option}
|
||||
>
|
||||
<span className="block truncate">{option.label}</span>
|
||||
</Combobox.Option>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox value={props.selection} onChange={(val) => props.onChange(val)}>
|
||||
<div className="relative ml-2 w-full">
|
||||
<Combobox.Button as="div" className="relative dark:bg-gray-900 dark:text-gray-300 block rounded-md shadow-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-200 focus-within:ring-indigo-500 focus-within:border-indigo-500 ">
|
||||
<Combobox.Input
|
||||
className="border-none rounded-md focus:outline-none focus:ring-0 pr-10 text-sm"
|
||||
style={{backgroundColor: 'inherit'}}
|
||||
placeholder={props.placeholder}
|
||||
displayValue={(item) => item && item.label}
|
||||
onChange={(event) => debouncedFetchOptions(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{!loading && <ChevronDownIcon className="h-4 w-4 text-gray-500" />}
|
||||
{loading && <Spinner />}
|
||||
</div>
|
||||
</Combobox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
beforeEnter={onOpen}
|
||||
>
|
||||
<Combobox.Options className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900">
|
||||
{renderOptions()}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
@ -4,21 +4,22 @@ import { AdjustmentsVerticalIcon, MagnifyingGlassIcon, XMarkIcon, PencilSquareIc
|
||||
import classNames from 'classnames'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
|
||||
import { appliedFilters, navigateToQuery, formattedFilters } from './query'
|
||||
import { appliedFilters, navigateToQuery } from './query'
|
||||
import {
|
||||
FILTER_GROUPS,
|
||||
formatFilterGroup,
|
||||
filterGroupForFilter,
|
||||
parseQueryFilter
|
||||
} from "./stats/modals/filter";
|
||||
parseQueryFilter,
|
||||
formattedFilters
|
||||
} from "./util/filters";
|
||||
|
||||
function removeFilter(key, history, query) {
|
||||
const newOpts = {
|
||||
[key]: false
|
||||
}
|
||||
if (key === 'country') { newOpts.country_name = false }
|
||||
if (key === 'region') { newOpts.region_name = false }
|
||||
if (key === 'city') { newOpts.city_name = false }
|
||||
if (key === 'country') { newOpts.country_labels = false }
|
||||
if (key === 'region') { newOpts.region_labels = false }
|
||||
if (key === 'city') { newOpts.city_labels = false }
|
||||
|
||||
navigateToQuery(
|
||||
history,
|
||||
@ -36,51 +37,15 @@ function clearAllFilters(history, query) {
|
||||
);
|
||||
}
|
||||
|
||||
function filterText(key, rawValue, query) {
|
||||
const {type, value} = parseQueryFilter(rawValue)
|
||||
|
||||
if (key === "goal") {
|
||||
return <>Completed goal <b>{value}</b></>
|
||||
}
|
||||
if (key === "props") {
|
||||
const [metaKey, metaValue] = Object.entries(value)[0]
|
||||
const eventName = query.filters.goal ? query.filters.goal : 'event'
|
||||
const metaFilter = parseQueryFilter(metaValue)
|
||||
return <>{eventName}.{metaKey} {metaFilter.type} <b>{metaFilter.value}</b></>
|
||||
}
|
||||
if (key === "browser_version") {
|
||||
const isNotSet = query.filters.browser === '(not set)' || (!query.filters.browser)
|
||||
const browserName = isNotSet ? 'Browser' : query.filters.browser
|
||||
return <>{browserName}.Version {type} <b>{value}</b></>
|
||||
}
|
||||
if (key === "os_version") {
|
||||
const isNotSet = query.filters.os === '(not set)' || (!query.filters.os)
|
||||
const osName = isNotSet ? 'Operating System' : query.filters.os
|
||||
console.info('osname', osName)
|
||||
return <>{osName}.Version {type} <b>{value}</b></>
|
||||
}
|
||||
if (key === "country") {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const countryName = q.get('country_name')
|
||||
return <>Country {type} <b>{countryName}</b></>
|
||||
}
|
||||
|
||||
if (key === "region") {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const regionName = q.get('region_name')
|
||||
return <>Region {type} <b>{regionName}</b></>
|
||||
}
|
||||
|
||||
if (key === "city") {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const cityName = q.get('city_name')
|
||||
return <>City {type} <b>{cityName}</b></>
|
||||
}
|
||||
|
||||
function filterText(key, _rawValue, query) {
|
||||
const {type, clauses} = parseQueryFilter(query, key)
|
||||
const formattedFilter = formattedFilters[key]
|
||||
|
||||
if (formattedFilter) {
|
||||
return <>{formattedFilter} {type} <b>{value}</b></>
|
||||
if (key === "props") {
|
||||
const [[propKey, _propValue]] = Object.entries(query.filters['props'])
|
||||
return <>props.{propKey} {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
|
||||
} else if (formattedFilter) {
|
||||
return <>{formattedFilter} {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
|
||||
}
|
||||
|
||||
throw new Error(`Unknown filter: ${key}`)
|
||||
|
@ -20,7 +20,6 @@ export function shouldIgnoreKeypress(event) {
|
||||
return modifierPressed || isTyping
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether the given keybinding has been pressed and should be
|
||||
* processed. Events can be ignored based on `shouldIgnoreKeypress(event)`.
|
||||
|
@ -161,28 +161,3 @@ export function eventName(query) {
|
||||
}
|
||||
return 'pageviews'
|
||||
}
|
||||
|
||||
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',
|
||||
'entry_page': 'Entry Page',
|
||||
'exit_page': 'Exit Page'
|
||||
}
|
||||
|
@ -26,14 +26,13 @@ function BrowserVersions({ query, site }) {
|
||||
}
|
||||
|
||||
const isNotSet = query.filters.browser === '(not set)'
|
||||
const browserName = isNotSet ? 'Browser' : query.filters.browser
|
||||
const filter = isNotSet ? {} : { browser_version: 'name' }
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={filter}
|
||||
keyLabel={browserName + ' version'}
|
||||
keyLabel="Browser version"
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
@ -61,14 +60,13 @@ function OperatingSystemVersions({ query, site }) {
|
||||
}
|
||||
|
||||
const isNotSet = query.filters.os === '(not set)'
|
||||
const osName = isNotSet ? 'Operating System' : query.filters.os
|
||||
const filter = isNotSet ? {} : { os_version: 'name' }
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={filter}
|
||||
keyLabel={osName + ' version'}
|
||||
keyLabel="Operating System Version"
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
|
@ -21,7 +21,7 @@ function Countries({query, site, onClick}) {
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{country: 'code', country_name: 'name'}}
|
||||
filter={{country: 'code', country_labels: 'name'}}
|
||||
onClick={onClick}
|
||||
keyLabel="Country"
|
||||
detailsLink={sitePath(site, '/countries')}
|
||||
@ -44,7 +44,7 @@ function Regions({query, site, onClick}) {
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{region: 'code', region_name: 'name'}}
|
||||
filter={{region: 'code', region_labels: 'name'}}
|
||||
onClick={onClick}
|
||||
keyLabel="Region"
|
||||
detailsLink={sitePath(site, '/regions')}
|
||||
@ -67,7 +67,7 @@ function Cities({query, site}) {
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{city: 'code', city_name: 'name'}}
|
||||
filter={{city: 'code', city_labels: 'name'}}
|
||||
keyLabel="City"
|
||||
detailsLink={sitePath(site, '/cities')}
|
||||
query={query}
|
||||
|
@ -6,21 +6,11 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
import Combobox from '../../components/combobox'
|
||||
import Modal from './modal'
|
||||
import { parseQuery, formattedFilters } from '../../query'
|
||||
import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_TYPES } from '../../util/filters'
|
||||
import { parseQuery } from '../../query'
|
||||
import * as api from '../../api'
|
||||
import { apiPath, siteBasePath } from '../../util/url'
|
||||
|
||||
export const FILTER_GROUPS = {
|
||||
'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': ['prop_key', 'prop_value']
|
||||
}
|
||||
import { shouldIgnoreKeypress } from '../../keybinding'
|
||||
|
||||
function getFormState(filterGroup, query) {
|
||||
if (filterGroup === 'props') {
|
||||
@ -28,63 +18,23 @@ function getFormState(filterGroup, query) {
|
||||
const entries = propsObject && Object.entries(propsObject)
|
||||
|
||||
if (entries && entries.length == 1) {
|
||||
const propKey = entries[0][0]
|
||||
const {type, value} = parseQueryFilter(entries[0][1])
|
||||
const [[propKey, _propVal]] = entries
|
||||
const {type, clauses} = parseQueryFilter(query, 'props')
|
||||
|
||||
return {
|
||||
'prop_key': { label: propKey, value: propKey, type: FILTER_TYPES.is },
|
||||
'prop_value': { label: value, value: value, type: type }
|
||||
'prop_key': { type: FILTER_TYPES.is, clauses: [{label: propKey, value: propKey}] },
|
||||
'prop_value': { type, clauses }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
|
||||
const rawFilterValue = query.filters[filter] || ''
|
||||
const {type, value} = parseQueryFilter(rawFilterValue)
|
||||
const {type, clauses} = parseQueryFilter(query, filter)
|
||||
|
||||
let filterLabel = value
|
||||
|
||||
if (filter === 'country' && value !== '') {
|
||||
filterLabel = (new URLSearchParams(window.location.search)).get('country_name')
|
||||
}
|
||||
if (filter === 'region' && value !== '') {
|
||||
filterLabel = (new URLSearchParams(window.location.search)).get('region_name')
|
||||
}
|
||||
if (filter === 'city' && value !== '') {
|
||||
filterLabel = (new URLSearchParams(window.location.search)).get('city_name')
|
||||
}
|
||||
return Object.assign(result, { [filter]: { label: filterLabel, value: value, type } })
|
||||
return Object.assign(result, { [filter]: { type, clauses } })
|
||||
}, {})
|
||||
}
|
||||
|
||||
const FILTER_TYPES = {
|
||||
isNot: 'is not',
|
||||
contains: 'contains',
|
||||
is: 'is'
|
||||
};
|
||||
|
||||
const FILTER_PREFIXES = {
|
||||
[FILTER_TYPES.isNot]: '!',
|
||||
[FILTER_TYPES.contains]: '~',
|
||||
[FILTER_TYPES.is]: ''
|
||||
};
|
||||
|
||||
export function parseQueryFilter(queryFilter) {
|
||||
const type = Object.keys(FILTER_PREFIXES)
|
||||
.find(type => FILTER_PREFIXES[type] === queryFilter[0]) || FILTER_TYPES.is;
|
||||
|
||||
const value = [FILTER_TYPES.isNot, FILTER_TYPES.contains].includes(type)
|
||||
? queryFilter.substring(1)
|
||||
: queryFilter;
|
||||
|
||||
return {type, value}
|
||||
}
|
||||
|
||||
function toFilterQuery(value, type) {
|
||||
const prefix = FILTER_PREFIXES[type];
|
||||
return prefix + value.trim();
|
||||
}
|
||||
|
||||
function supportsContains(filterName) {
|
||||
return ['page', 'entry_page', 'exit_page'].includes(filterName)
|
||||
}
|
||||
@ -103,32 +53,6 @@ function withIndefiniteArticle(word) {
|
||||
|
||||
}
|
||||
|
||||
export function formatFilterGroup(filterGroup) {
|
||||
if (filterGroup === 'utm') {
|
||||
return 'UTM tags'
|
||||
} else if (filterGroup === 'location') {
|
||||
return 'Location'
|
||||
} else if (filterGroup === 'props') {
|
||||
return 'Property'
|
||||
} else {
|
||||
return formattedFilters[filterGroup]
|
||||
}
|
||||
}
|
||||
|
||||
export function filterGroupForFilter(filter) {
|
||||
const map = Object.entries(FILTER_GROUPS).reduce((filterToGroupMap, [group, filtersInGroup]) => {
|
||||
const filtersToAdd = {}
|
||||
filtersInGroup.forEach((filterInGroup) => {
|
||||
filtersToAdd[filterInGroup] = group
|
||||
})
|
||||
|
||||
return { ...filterToGroupMap, ...filtersToAdd }
|
||||
}, {})
|
||||
|
||||
|
||||
return map[filter] || filter
|
||||
}
|
||||
|
||||
class FilterModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
@ -148,9 +72,7 @@ class FilterModal extends React.Component {
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing || e.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
if (shouldIgnoreKeypress(e)) return
|
||||
|
||||
if (e.target.tagName === 'BODY' && e.key === 'Enter') {
|
||||
this.handleSubmit()
|
||||
@ -160,19 +82,19 @@ class FilterModal extends React.Component {
|
||||
handleSubmit() {
|
||||
const { formState } = this.state;
|
||||
|
||||
const filters = Object.entries(formState).reduce((res, [filterKey, { type, value, label }]) => {
|
||||
if (filterKey === 'country') { res.push({ filter: 'country_name', value: label }) }
|
||||
if (filterKey === 'region') { res.push({ filter: 'region_name', value: label }) }
|
||||
if (filterKey === 'city') { res.push({ filter: 'city_name', value: label }) }
|
||||
const filters = Object.entries(formState).reduce((res, [filterKey, { type, clauses }]) => {
|
||||
if (clauses.length === 0) { return res }
|
||||
if (filterKey === 'country') { res.push({ filter: 'country_labels', value: clauses.map(clause => clause.label).join('|') }) }
|
||||
if (filterKey === 'region') { res.push({ filter: 'region_labels', value: clauses.map(clause => clause.label).join('|') }) }
|
||||
if (filterKey === 'city') { res.push({ filter: 'city_labels', value: clauses.map(clause => clause.label).join('|') }) }
|
||||
if (filterKey === 'prop_value') { return res }
|
||||
if (filterKey === 'prop_key') {
|
||||
let propValue = formState['prop_value']
|
||||
let filterValue = JSON.stringify({ [value]: toFilterQuery(propValue.value, propValue.type) })
|
||||
res.push({ filter: 'props', value: filterValue })
|
||||
const [{value: propKey}] = clauses
|
||||
res.push({ filter: 'props', value: JSON.stringify({ [propKey]: toFilterQuery(formState.prop_value.type, formState.prop_value.clauses) }) })
|
||||
return res
|
||||
}
|
||||
|
||||
res.push({ filter: filterKey, value: toFilterQuery(value, type) })
|
||||
res.push({ filter: filterKey, value: toFilterQuery(type, clauses) })
|
||||
return res
|
||||
}, [])
|
||||
|
||||
@ -183,7 +105,7 @@ class FilterModal extends React.Component {
|
||||
return (selection) => {
|
||||
this.setState(prevState => ({
|
||||
formState: Object.assign(prevState.formState, {
|
||||
[filterName]: Object.assign(prevState.formState[filterName], selection)
|
||||
[filterName]: Object.assign(prevState.formState[filterName], { clauses: selection })
|
||||
})
|
||||
}))
|
||||
}
|
||||
@ -201,7 +123,7 @@ class FilterModal extends React.Component {
|
||||
return (input) => {
|
||||
const { query, formState } = this.state
|
||||
const formFilters = Object.fromEntries(
|
||||
Object.entries(formState).map(([k, v]) => [k, v.code || v.value])
|
||||
Object.entries(formState).map(([filter, {type, clauses}]) => [filter, toFilterQuery(type, clauses)])
|
||||
)
|
||||
const updatedQuery = this.queryForSuggestions(query, formFilters, filter)
|
||||
return api.get(apiPath(this.props.site, `/suggestions/${filter}`), updatedQuery, { q: input.trim() })
|
||||
@ -216,7 +138,17 @@ class FilterModal extends React.Component {
|
||||
const propsFilter = formFilters.prop_key ? { [formFilters.prop_key]: '!(none)' } : null
|
||||
return { ...query, filters: { ...query.filters, props: propsFilter } }
|
||||
} else {
|
||||
return { ...query, filters: { ...query.filters, ...formFilters, [filter]: null } }
|
||||
return { ...query, filters: { ...query.filters, ...formFilters, [filter]: this.negate(formFilters[filter]) } }
|
||||
}
|
||||
}
|
||||
|
||||
negate(filterVal) {
|
||||
if (!filterVal) {
|
||||
return filterVal
|
||||
} else if (filterVal.startsWith('!')) {
|
||||
return filterVal
|
||||
} else {
|
||||
return '!' + filterVal
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,7 +157,11 @@ class FilterModal extends React.Component {
|
||||
}
|
||||
|
||||
isDisabled() {
|
||||
return Object.entries(this.state.formState).every(([_key, { value: val }]) => !val)
|
||||
if (this.state.selectedFilterGroup === 'props') {
|
||||
return Object.entries(this.state.formState).some(([_key, { clauses }]) => clauses.length === 0)
|
||||
} else {
|
||||
return Object.entries(this.state.formState).every(([_key, { clauses }]) => clauses.length === 0)
|
||||
}
|
||||
}
|
||||
|
||||
selectFiltersAndCloseModal(filters) {
|
||||
@ -243,8 +179,8 @@ class FilterModal extends React.Component {
|
||||
}
|
||||
|
||||
renderSearchBox(filter) {
|
||||
const isStrict = this.state.selectedFilterGroup === 'location'
|
||||
return <Combobox fetchOptions={this.fetchOptions(filter)} strict={isStrict} selection={this.state.formState[filter]} onChange={this.onChange(filter)} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} />
|
||||
const freeChoice = this.state.selectedFilterGroup === 'page'
|
||||
return <Combobox fetchOptions={this.fetchOptions(filter)} freeChoice={freeChoice} values={this.state.formState[filter].clauses} onChange={this.onChange(filter)} placeholder={`Select ${withIndefiniteArticle(formattedFilters[filter])}`} />
|
||||
}
|
||||
|
||||
renderFilterInputs() {
|
||||
@ -332,7 +268,7 @@ class FilterModal extends React.Component {
|
||||
|
||||
<div className="mt-4 border-b border-gray-300"></div>
|
||||
<main className="modal__content">
|
||||
<form className="flex flex-col" id="filter-form" onSubmit={this.handleSubmit.bind(this)}>
|
||||
<form className="flex flex-col" onSubmit={this.handleSubmit.bind(this)}>
|
||||
{this.renderFilterInputs()}
|
||||
|
||||
<div className="mt-6 flex items-center justify-start">
|
||||
@ -341,7 +277,7 @@ class FilterModal extends React.Component {
|
||||
className="button"
|
||||
disabled={this.isDisabled()}
|
||||
>
|
||||
Save Filter
|
||||
Apply Filter
|
||||
</button>
|
||||
|
||||
{showClear && (
|
||||
@ -359,22 +295,11 @@ class FilterModal extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{this.renderHints()}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
renderHints() {
|
||||
if (['page', 'entry_page', 'exit_page'].includes(this.state.selectedFilterGroup)) {
|
||||
return (
|
||||
<p className="mt-6 text-xs text-gray-500">Hint: You can use double asterisks to match any character e.g. /blog** to group all of your blog posts. Or use double asterisks in front and back (e.g. **keyword**) to group all URLs containing a specific keyword.</p>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal site={this.props.site} maxWidth="460px">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { shouldIgnoreKeypress } from '../../keybinding'
|
||||
|
||||
// This corresponds to the 'md' breakpoint on TailwindCSS.
|
||||
const MD_WIDTH = 768;
|
||||
@ -46,7 +47,7 @@ class Modal extends React.Component {
|
||||
}
|
||||
|
||||
handleKeyup(e) {
|
||||
if (e.code === 'Escape') {
|
||||
if (!shouldIgnoreKeypress(e) && e.code === 'Escape') {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
146
assets/js/dashboard/util/filters.js
Normal file
146
assets/js/dashboard/util/filters.js
Normal file
@ -0,0 +1,146 @@
|
||||
export const FILTER_GROUPS = {
|
||||
'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': ['prop_key', 'prop_value']
|
||||
}
|
||||
|
||||
export const FILTER_TYPES = {
|
||||
isNot: 'is not',
|
||||
contains: 'contains',
|
||||
is: 'is'
|
||||
};
|
||||
|
||||
export const FILTER_PREFIXES = {
|
||||
[FILTER_TYPES.isNot]: '!',
|
||||
[FILTER_TYPES.contains]: '~',
|
||||
[FILTER_TYPES.is]: ''
|
||||
};
|
||||
|
||||
// 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;
|
||||
try {
|
||||
NON_ESCAPED_PIPE_REGEX = new RegExp("(?<!\\\\)\\|", "g")
|
||||
} catch(_e) {
|
||||
NON_ESCAPED_PIPE_REGEX = '|'
|
||||
}
|
||||
|
||||
console.log(NON_ESCAPED_PIPE_REGEX)
|
||||
|
||||
const ESCAPED_PIPE = '\\|'
|
||||
|
||||
function escapeFilterValue(value) {
|
||||
return value.replaceAll(NON_ESCAPED_PIPE_REGEX, ESCAPED_PIPE)
|
||||
}
|
||||
|
||||
export function toFilterQuery(type, clauses) {
|
||||
const prefix = FILTER_PREFIXES[type];
|
||||
const result = clauses.map(clause => escapeFilterValue(clause.value.trim())).join('|')
|
||||
return prefix + result;
|
||||
}
|
||||
|
||||
function parsePrefix(rawValue) {
|
||||
const type = Object.keys(FILTER_PREFIXES)
|
||||
.find(type => FILTER_PREFIXES[type] === rawValue[0]) || FILTER_TYPES.is;
|
||||
|
||||
const value = [FILTER_TYPES.isNot, FILTER_TYPES.contains].includes(type)
|
||||
? rawValue.substring(1)
|
||||
: rawValue;
|
||||
|
||||
const values = value
|
||||
.split(NON_ESCAPED_PIPE_REGEX)
|
||||
.filter((clause) => !!clause)
|
||||
.map((val) => val.replaceAll(ESCAPED_PIPE, '|'))
|
||||
|
||||
return {type, values}
|
||||
}
|
||||
|
||||
export function parseQueryFilter(query, filter) {
|
||||
if (filter === 'props') {
|
||||
const rawValue = query.filters['props']
|
||||
const [[_propKey, propVal]] = Object.entries(rawValue)
|
||||
const {type, values} = parsePrefix(propVal)
|
||||
const clauses = values.map(val => { return {value: val, label: val}})
|
||||
return {type, clauses}
|
||||
} else {
|
||||
const {type, values} = parsePrefix(query.filters[filter] || '')
|
||||
|
||||
let labels = values
|
||||
|
||||
if (filter === 'country' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('country_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
if (filter === 'region' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('region_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
if (filter === 'city' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('city_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
const clauses = values.map((value, index) => { return {value, label: labels[index]}})
|
||||
|
||||
return {type, clauses}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFilterGroup(filterGroup) {
|
||||
if (filterGroup === 'utm') {
|
||||
return 'UTM tags'
|
||||
} else if (filterGroup === 'location') {
|
||||
return 'Location'
|
||||
} else if (filterGroup === 'props') {
|
||||
return 'Property'
|
||||
} else {
|
||||
return formattedFilters[filterGroup]
|
||||
}
|
||||
}
|
||||
|
||||
export function filterGroupForFilter(filter) {
|
||||
const map = Object.entries(FILTER_GROUPS).reduce((filterToGroupMap, [group, filtersInGroup]) => {
|
||||
const filtersToAdd = {}
|
||||
filtersInGroup.forEach((filterInGroup) => {
|
||||
filtersToAdd[filterInGroup] = group
|
||||
})
|
||||
|
||||
return { ...filterToGroupMap, ...filtersToAdd }
|
||||
}, {})
|
||||
|
||||
|
||||
return map[filter] || filter
|
||||
}
|
||||
|
||||
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',
|
||||
'entry_page': 'Entry Page',
|
||||
'exit_page': 'Exit Page'
|
||||
}
|
@ -513,10 +513,16 @@ defmodule Plausible.Stats.Base do
|
||||
{first_datetime, last_datetime}
|
||||
end
|
||||
|
||||
@replaces %{
|
||||
~r/\*\*/ => ".*",
|
||||
~r/(?<!\.)\*/ => "[^/]*",
|
||||
"(" => "\\(",
|
||||
")" => "\\)"
|
||||
}
|
||||
def page_regex(expr) do
|
||||
"^#{expr}$"
|
||||
|> String.replace(~r/\*\*/, ".*")
|
||||
|> String.replace(~r/(?<!\.)\*/, "[^/]*")
|
||||
Enum.reduce(@replaces, "^#{expr}$", fn {pattern, replacement}, regex ->
|
||||
String.replace(regex, pattern, replacement)
|
||||
end)
|
||||
end
|
||||
|
||||
defp add_sample_hint(db_q, query) do
|
||||
|
@ -63,6 +63,7 @@ defmodule Plausible.Stats.Filters do
|
||||
cond do
|
||||
is_negated && is_wildcard && is_list -> {:not_matches_member, val}
|
||||
is_negated && is_contains && is_list -> {:not_matches_member, Enum.map(val, &"**#{&1}**")}
|
||||
is_wildcard && is_list -> {:matches_member, val}
|
||||
is_negated && is_wildcard -> {:does_not_match, val}
|
||||
is_negated && is_list -> {:not_member, val}
|
||||
is_negated && is_contains -> {:does_not_match, "**" <> val <> "**"}
|
||||
|
@ -515,6 +515,51 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
|
||||
]
|
||||
end
|
||||
|
||||
test "page filter escapes brackets",
|
||||
%{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
build(:pageview,
|
||||
pathname: "/blog/(/post-1",
|
||||
user_id: @user_id,
|
||||
timestamp: ~N[2021-01-01 00:00:00]
|
||||
),
|
||||
build(:pageview,
|
||||
pathname: "/blog/(/post-2",
|
||||
user_id: @user_id,
|
||||
timestamp: ~N[2021-01-01 00:01:00]
|
||||
),
|
||||
build(:pageview,
|
||||
pathname: "/",
|
||||
timestamp: ~N[2021-01-01 00:00:00]
|
||||
)
|
||||
])
|
||||
|
||||
filters = Jason.encode!(%{page: "/blog/(/**|/blog/)/**"})
|
||||
|
||||
conn =
|
||||
get(
|
||||
conn,
|
||||
"/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true"
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "/blog/(/post-1",
|
||||
"visitors" => 1,
|
||||
"pageviews" => 1,
|
||||
"bounce_rate" => 0,
|
||||
"time_on_page" => 60
|
||||
},
|
||||
%{
|
||||
"name" => "/blog/(/post-2",
|
||||
"visitors" => 1,
|
||||
"pageviews" => 1,
|
||||
"bounce_rate" => nil,
|
||||
"time_on_page" => nil
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "can filter using the not_matches_member filter type",
|
||||
%{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
|
Loading…
Reference in New Issue
Block a user