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:
Uku Taht 2023-03-27 16:51:31 +03:00 committed by GitHub
parent d2f2c69387
commit 46048e50f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 476 additions and 297 deletions

View File

@ -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">&times;</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>
)
}

View File

@ -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}`)

View File

@ -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)`.
@ -30,6 +29,6 @@ export function shouldIgnoreKeypress(event) {
*
*/
export function isKeyPressed(event, keybinding) {
const keyPressed = event.key.toLowerCase() == keybinding.toLowerCase()
const keyPressed = event.key.toLowerCase() == keybinding.toLowerCase()
return keyPressed && !shouldIgnoreKeypress(event)
}

View File

@ -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'
}

View File

@ -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}
/>
)

View File

@ -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}

View File

@ -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">

View File

@ -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()
}
}

View 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'
}

View File

@ -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

View File

@ -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 <> "**"}

View File

@ -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, [