Filtering by multiple custom properties (#3719)

* WIP: PropFilterRow

* Get multi-behavior working

* Render multiple prop filters in one

* Modal reads from query string correctly

* Backend support for multiple custom property filters

* Add backend tests for multiple custom property filters

* Disable already selected options in property keys

We can't allow choosing the same property multiple times without changing the request
params, which we decided against

* Allow choosing any property under Behaviors > Custom props even if custom prop filter applied

This was a limitation (I believe) introduced by using ARRAY JOINs to query custom properties

* CHANGELOG.md

* Solve credo warning about too deep nesting

* Update assets/js/dashboard/stats/modals/prop-filter-modal.js

Co-authored-by: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com>

* Refactor internal function for clarity

* Add another step -> Add another

* Solve 500 error

* Separate boxes per property filter

* Retain other filters in props table

* removeFilter behavior for props

* matches_member support for custom props

* filter_suggestions for prop keys should account for prop filter

* find over filter

* refactor appliedFilters

* FILTER_TYPES => FILTER_OPERATIONS

* Make add another link not wrap the whole page

* Unique keys

---------

Co-authored-by: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com>
This commit is contained in:
Karl-Aksel Puulmann 2024-02-12 09:03:57 +02:00 committed by GitHub
parent c8b25347ed
commit 1cb7982cd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 687 additions and 222 deletions

View File

@ -2,6 +2,7 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
### Added ### Added
- Allow filtering by multiple custom properties
- Wildcard and member filtering on the Stats API `event:goal` property - Wildcard and member filtering on the Stats API `event:goal` property
- Allow filtering with `contains`/`matches` operator for custom properties - Allow filtering with `contains`/`matches` operator for custom properties
- Add `referrers.csv` to CSV export - Add `referrers.csv` to CSV export

View File

@ -91,8 +91,10 @@ export default function PlausibleCombobox(props) {
} }
} }
function isDisabled(option) { function isOptionDisabled(option) {
return props.values.some((val) => val.value === option.value) const optionAlreadySelected = props.values.some((val) => val.value === option.value)
const optionDisabled = (props.disabledOptions || []).some((val) => val?.value === option.value)
return optionAlreadySelected || optionDisabled
} }
function fetchOptions(query) { function fetchOptions(query) {
@ -217,7 +219,7 @@ export default function PlausibleCombobox(props) {
} }
function renderDropDownContent() { function renderDropDownContent() {
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isDisabled(option)) const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isOptionDisabled(option))
if (loading) { if (loading) {
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Loading options...</div> return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Loading options...</div>
@ -225,7 +227,7 @@ export default function PlausibleCombobox(props) {
if (matchesFound) { if (matchesFound) {
return visibleOptions return visibleOptions
.filter(option => !isDisabled(option)) .filter(option => !isOptionDisabled(option))
.map((option, i) => { .map((option, i) => {
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label const text = option.freeChoice ? `Filter by '${option.label}'` : option.label

View File

@ -1,6 +1,6 @@
import React, { Fragment } from "react"; import React, { Fragment } from "react";
import { FILTER_TYPES } from "../util/filters"; import { FILTER_OPERATIONS } from "../util/filters";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { isFreeChoiceFilter, supportsIsNot } from "../util/filters"; import { isFreeChoiceFilter, supportsIsNot } from "../util/filters";
@ -61,9 +61,9 @@ export default function FilterTypeSelector(props) {
className="z-10 origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none" className="z-10 origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
> >
<div className="py-1"> <div className="py-1">
{renderTypeItem(FILTER_TYPES.is, true)} {renderTypeItem(FILTER_OPERATIONS.is, true)}
{renderTypeItem(FILTER_TYPES.isNot, supportsIsNot(filterName))} {renderTypeItem(FILTER_OPERATIONS.isNot, supportsIsNot(filterName))}
{renderTypeItem(FILTER_TYPES.contains, isFreeChoiceFilter(filterName))} {renderTypeItem(FILTER_OPERATIONS.contains, isFreeChoiceFilter(filterName))}
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>

View File

@ -10,12 +10,23 @@ import {
formatFilterGroup, formatFilterGroup,
filterGroupForFilter, filterGroupForFilter,
parseQueryFilter, parseQueryFilter,
parseQueryPropsFilter,
formattedFilters formattedFilters
} from "./util/filters"; } from "./util/filters";
function removeFilter(key, history, query) { function removeFilter(filterType, key, history, query) {
const newOpts = { const newOpts = {}
[key]: false if (filterType === 'props') {
if (Object.keys(query.filters.props).length == 1) {
newOpts.props = false
} else {
newOpts.props = JSON.stringify({
...query.filters.props,
[key]: undefined,
})
}
} else {
newOpts[key] = false
} }
if (key === 'country') { newOpts.country_labels = false } if (key === 'country') { newOpts.country_labels = false }
if (key === 'region') { newOpts.region_labels = false } if (key === 'region') { newOpts.region_labels = false }
@ -37,34 +48,38 @@ function clearAllFilters(history, query) {
); );
} }
function filterText(key, _rawValue, query) { function filterText(filterType, key, query) {
const {type, clauses} = parseQueryFilter(query, key)
const formattedFilter = formattedFilters[key] const formattedFilter = formattedFilters[key]
if (key === "props") { if (filterType === "props") {
const [[propKey, _propValue]] = Object.entries(query.filters['props']) const { propKey, clauses, type } = parseQueryPropsFilter(query).find((filter) => filter.propKey.value === key)
return <>Property <b>{propKey}</b> {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </> return <>Property <b>{propKey.label}</b> {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
} else if (formattedFilter) { } else if (formattedFilter) {
const {type, clauses} = parseQueryFilter(query, key)
return <>{formattedFilter} {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </> return <>{formattedFilter} {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
} }
throw new Error(`Unknown filter: ${key}`) throw new Error(`Unknown filter: ${key}`)
} }
function renderDropdownFilter(site, history, [key, value], query) { function renderDropdownFilter(site, history, { key, value, filterType }, query) {
return ( return (
<Menu.Item key={key}> <Menu.Item key={`${filterType}::${key}`}>
<div className="px-3 md:px-4 sm:py-2 py-3 text-sm leading-tight flex items-center justify-between" key={key + value}> <div className="px-3 md:px-4 sm:py-2 py-3 text-sm leading-tight flex items-center justify-between" key={key + value}>
<Link <Link
title={`Edit filter: ${formattedFilters[key]}`} title={`Edit filter: ${formattedFilters[filterType]}`}
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${filterGroupForFilter(key)}`, search: window.location.search }} to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${filterGroupForFilter(filterType)}`, search: window.location.search }}
className="group flex w-full justify-between items-center" className="group flex w-full justify-between items-center"
style={{ width: 'calc(100% - 1.5rem)' }} style={{ width: 'calc(100% - 1.5rem)' }}
> >
<span className="inline-block w-full truncate">{filterText(key, value, query)}</span> <span className="inline-block w-full truncate">{filterText(filterType, key, query)}</span>
<PencilSquareIcon className="w-4 h-4 ml-1 cursor-pointer group-hover:text-indigo-700 dark:group-hover:text-indigo-500" /> <PencilSquareIcon className="w-4 h-4 ml-1 cursor-pointer group-hover:text-indigo-700 dark:group-hover:text-indigo-500" />
</Link> </Link>
<b title={`Remove filter: ${formattedFilters[key]}`} className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500" onClick={() => removeFilter(key, history, query)}> <b
title={`Remove filter: ${formattedFilters[filterType]}`}
className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500"
onClick={() => removeFilter(filterType, key, history, query)}
>
<XMarkIcon className="w-4 h-4" /> <XMarkIcon className="w-4 h-4" />
</b> </b>
</div> </div>
@ -202,13 +217,21 @@ class Filters extends React.Component {
}); });
}; };
renderListFilter(history, [key, value], query) { renderListFilter(history, { key, value, filterType }, query) {
return ( return (
<span key={key} title={value} className="flex bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow text-sm rounded mr-2 items-center"> <span key={`${filterType}::${key}`} title={value} className="flex bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow text-sm rounded mr-2 items-center">
<Link title={`Edit filter: ${formattedFilters[key]}`} className="flex w-full h-full items-center py-2 pl-3" to={{ pathname: `/${encodeURIComponent(this.props.site.domain)}/filter/${filterGroupForFilter(key)}`, search: window.location.search }}> <Link
<span className="inline-block max-w-2xs md:max-w-xs truncate">{filterText(key, value, query)}</span> title={`Edit filter: ${formattedFilters[filterType]}`}
className="flex w-full h-full items-center py-2 pl-3"
to={{ pathname: `/${encodeURIComponent(this.props.site.domain)}/filter/${filterGroupForFilter(filterType)}`, search: window.location.search }}
>
<span className="inline-block max-w-2xs md:max-w-xs truncate">{filterText(filterType, key, query)}</span>
</Link> </Link>
<span title={`Remove filter: ${formattedFilters[key]}`} className="flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center" onClick={() => removeFilter(key, history, query)}> <span
title={`Remove filter: ${formattedFilters[filterType]}`}
className="flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center"
onClick={() => removeFilter(filterType, key, history, query)}
>
<XMarkIcon className="w-4 h-4" /> <XMarkIcon className="w-4 h-4" />
</span> </span>
</span> </span>

View File

@ -65,9 +65,13 @@ export function parseQuery(querystring, site) {
} }
export function appliedFilters(query) { export function appliedFilters(query) {
return Object.keys(query.filters) const propKeys = Object.entries(query.filters.props || {})
.map((key) => [key, query.filters[key]]) .map(([key, value]) => ({ key, value, filterType: 'props' }))
.filter(([_key, value]) => !!value);
return Object.entries(query.filters)
.map(([key, value]) => ({ key, value, filterType: key }))
.filter(({ key, value }) => key !== 'props' && !!value)
.concat(propKeys)
} }
function generateQueryString(data) { function generateQueryString(data) {

View File

@ -92,14 +92,16 @@ export default function Properties(props) {
) )
} }
const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } } const getFilterFor = (listItem) => ({
props: JSON.stringify({ ...query.filters.props, [propKey]: escapeFilterValue(listItem.name) })
})
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : [] const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500' const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<div> <div>
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} /> <Combobox boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div> </div>
{propKey && renderBreakdown()} {propKey && renderBreakdown()}
</div> </div>

View File

@ -1,30 +1,32 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useEffect, useState, useMemo } from 'react'
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import Combobox from '../../components/combobox' import { FILTER_OPERATIONS } from "../../util/filters";
import FilterTypeSelector from "../../components/filter-type-selector";
import { FILTER_TYPES } from "../../util/filters";
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import * as api from '../../api' import { siteBasePath } from '../../util/url'
import { apiPath, siteBasePath } from '../../util/url' import { toFilterQuery, parseQueryPropsFilter } from '../../util/filters';
import { toFilterQuery, parseQueryFilter } from '../../util/filters';
import { shouldIgnoreKeypress } from '../../keybinding'; import { shouldIgnoreKeypress } from '../../keybinding';
import PropFilterRow from './prop-filter-row';
function getFormState(query) { function getFormState(query) {
const rawValue = query.filters['props'] if (query.filters['props']) {
if (rawValue) { const values = Object.fromEntries(parseQueryPropsFilter(query, 'props').map((value, index) => [index, value]))
const [[propKey, _propValue]] = Object.entries(rawValue)
const {type, clauses} = parseQueryFilter(query, 'props')
return { return {
prop_key: {value: propKey, label: propKey}, entries: Object.keys(values).sort(),
prop_value: { type: type, clauses: clauses } values
} }
} } else {
return { return {
prop_key: null, entries: [0],
prop_value: { type: FILTER_TYPES.is, clauses: [] } values: {
0: {
propKey: null,
type: FILTER_OPERATIONS.is,
clauses: []
}
}
}
} }
} }
@ -32,79 +34,78 @@ function PropFilterModal(props) {
const query = parseQuery(props.location.search, props.site) const query = parseQuery(props.location.search, props.site)
const [formState, setFormState] = useState(getFormState(query)) const [formState, setFormState] = useState(getFormState(query))
function fetchPropKeyOptions() { const selectedPropKeys = useMemo(() => Object.values(formState.values).map((value) => value.propKey), [formState])
return (input) => {
return api.get(apiPath(props.site, "/suggestions/prop_key"), query, { q: input.trim() })
}
}
const fetchPropValueOptions = useCallback(() => { function onPropKeySelect(id, selectedOptions) {
return (input) => {
if (formState.prop_value?.type === FILTER_TYPES.contains) {
return Promise.resolve([])
}
const propKey = formState.prop_key?.value
const updatedQuery = { ...query, filters: { ...query.filters, props: {[propKey]: '!(none)'} } }
return api.get(apiPath(props.site, "/suggestions/prop_value"), updatedQuery, { q: input.trim() })
}
}, [formState.prop_key, formState.prop_value])
function onPropKeySelect() {
return (selectedOptions) => {
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0] const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0]
setFormState(prevState => ({ setFormState(prevState => ({
prop_key: newPropKey, ...prevState,
prop_value: { type: prevState.prop_value.type, clauses: [] } values: {
...prevState.values,
[id]: {
...prevState.values[id],
propKey: newPropKey,
clauses: []
}
}
})) }))
} }
}
function onPropValueSelect() { function onPropValueSelect(id, selection) {
return (selection) => {
setFormState(prevState => ({ setFormState(prevState => ({
...prevState, prop_value: { ...prevState.prop_value, clauses: selection } ...prevState,
values: {
...prevState.values,
[id]: {
...prevState.values[id],
clauses: selection
}
}
})) }))
} }
}
function onFilterTypeSelect() { function onFilterTypeSelect(id, newType) {
return (newType) => {
setFormState(prevState => ({ setFormState(prevState => ({
...prevState, prop_value: { ...prevState.prop_value, type: newType } ...prevState,
values: {
...prevState.values,
[id]: {
...prevState.values[id],
type: newType
}
}
})) }))
} }
function onPropAdd() {
setFormState(prevState => {
const id = prevState.entries[prevState.entries.length - 1] + 1
return {
entries: prevState.entries.concat([id]),
values: {
...prevState.values,
[id]: {
propKey: null,
type: FILTER_OPERATIONS.is,
clauses: []
}
}
}
})
} }
function selectedFilterType() { function onPropDelete(id) {
return formState.prop_value.type setFormState(prevState => {
} const entries = prevState.entries.filter((entry) => entry != id)
const values = {...prevState.values}
delete values[id]
function renderFilterInputs() { return { entries, values }
return ( })
<div className="grid grid-cols-11 mt-6">
<div className="col-span-4">
<Combobox className="mr-2" fetchOptions={fetchPropKeyOptions()} singleOption={true} values={formState.prop_key ? [formState.prop_key] : []} onSelect={onPropKeySelect()} placeholder={'Property'} />
</div>
<div className="col-span-3 mx-2">
<FilterTypeSelector isDisabled={!formState.prop_key} forFilter={'prop_value'} onSelect={onFilterTypeSelect()} selectedType={selectedFilterType()} />
</div>
<div className="col-span-4">
<Combobox
isDisabled={!formState.prop_key}
fetchOptions={fetchPropValueOptions()}
values={formState.prop_value.clauses}
onSelect={onPropValueSelect()}
placeholder={'Value'}
freeChoice={selectedFilterType() == FILTER_TYPES.contains}
/>
</div>
</div>
)
} }
function isDisabled() { function isDisabled() {
return !(formState.prop_key && formState.prop_value.clauses.length > 0) return formState.entries.some((id) => !formState.values[id].propKey || formState.values[id].clauses.length == 0)
} }
function shouldShowClear() { function shouldShowClear() {
@ -112,8 +113,11 @@ function PropFilterModal(props) {
} }
function handleSubmit() { function handleSubmit() {
const filterString = JSON.stringify({ [formState.prop_key.value]: toFilterQuery(formState.prop_value.type, formState.prop_value.clauses) }) const formFilters = Object.fromEntries(
selectFiltersAndCloseModal(filterString) Object.entries(formState.values)
.map(([_, {propKey, type, clauses}]) => [propKey.value, toFilterQuery(type, clauses)])
)
selectFiltersAndCloseModal(JSON.stringify(formFilters))
} }
function selectFiltersAndCloseModal(filterString) { function selectFiltersAndCloseModal(filterString) {
@ -150,7 +154,27 @@ function PropFilterModal(props) {
<div className="mt-4 border-b border-gray-300"></div> <div className="mt-4 border-b border-gray-300"></div>
<main className="modal__content"> <main className="modal__content">
<form className="flex flex-col" onSubmit={handleSubmit}> <form className="flex flex-col" onSubmit={handleSubmit}>
{renderFilterInputs()} {formState.entries.map((id) => (
<PropFilterRow
key={id}
id={id}
site={props.site}
query={query}
{...formState.values[id]}
showDelete={formState.entries.length > 1}
selectedPropKeys={selectedPropKeys}
onPropKeySelect={onPropKeySelect}
onPropValueSelect={onPropValueSelect}
onFilterTypeSelect={onFilterTypeSelect}
onPropDelete={onPropDelete}
/>
))}
<div className="mt-6">
<a className="underline text-indigo-500 text-sm cursor-pointer" onClick={onPropAdd}>
+ Add another
</a>
</div>
<div className="mt-6 flex items-center justify-start"> <div className="mt-6 flex items-center justify-start">
<button <button

View File

@ -0,0 +1,85 @@
import React from 'react'
import Combobox from '../../components/combobox'
import FilterTypeSelector from "../../components/filter-type-selector";
import { FILTER_OPERATIONS } from "../../util/filters";
import * as api from '../../api'
import { apiPath } from '../../util/url'
import { TrashIcon } from '@heroicons/react/20/solid'
function PropFilterRow({
id,
query,
site,
propKey,
type,
clauses,
showDelete,
selectedPropKeys,
onPropKeySelect,
onPropValueSelect,
onFilterTypeSelect,
onPropDelete
}) {
function fetchPropKeyOptions() {
return (input) => {
return api.get(apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
}
}
function fetchPropValueOptions() {
return (input) => {
if (type === FILTER_OPERATIONS.contains) {
return Promise.resolve([])
}
const key = propKey?.value
const updatedQuery = { ...query, filters: { ...query.filters, props: { [key]: '!(none)' } } }
return api.get(apiPath(site, "/suggestions/prop_value"), updatedQuery, { q: input.trim() })
}
}
return (
<div className="grid grid-cols-12 mt-6">
<div className="col-span-4">
<Combobox
className="mr-2"
fetchOptions={fetchPropKeyOptions()}
singleOption={true}
values={propKey ? [propKey] : []}
onSelect={(value) => onPropKeySelect(id, value)}
placeholder={'Property'}
disabledOptions={selectedPropKeys}
/>
</div>
<div className="col-span-3 mx-2">
<FilterTypeSelector
isDisabled={!propKey}
forFilter={'prop_value'}
onSelect={(value) => onFilterTypeSelect(id, value)}
selectedType={type}
/>
</div>
<div className="col-span-4">
<Combobox
isDisabled={!propKey}
fetchOptions={fetchPropValueOptions()}
values={clauses}
onSelect={(value) => onPropValueSelect(id, value)}
placeholder={'Value'}
freeChoice={type == FILTER_OPERATIONS.contains}
/>
</div>
{showDelete && (
<div className="col-span-1 flex flex-col justify-center">
<a className="ml-2 text-red-600 h-5 w-5 cursor-pointer" onClick={() => onPropDelete(id)}>
<TrashIcon />
</a>
</div>
)}
</div>
)
}
export default PropFilterRow

View File

@ -3,7 +3,7 @@ import { withRouter } from 'react-router-dom'
import FilterTypeSelector from "../../components/filter-type-selector"; import FilterTypeSelector from "../../components/filter-type-selector";
import Combobox from '../../components/combobox' import Combobox from '../../components/combobox'
import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_TYPES } from '../../util/filters' import { FILTER_GROUPS, parseQueryFilter, formatFilterGroup, formattedFilters, toFilterQuery, FILTER_OPERATIONS } from '../../util/filters'
import { parseQuery } from '../../query' import { parseQuery } from '../../query'
import * as api from '../../api' import * as api from '../../api'
import { apiPath, siteBasePath } from '../../util/url' import { apiPath, siteBasePath } from '../../util/url'
@ -92,7 +92,7 @@ class RegularFilterModal extends React.Component {
fetchOptions(filter) { fetchOptions(filter) {
return (input) => { return (input) => {
const { query, formState } = this.state const { query, formState } = this.state
if (formState[filter].type === FILTER_TYPES.contains) {return Promise.resolve([])} if (formState[filter].type === FILTER_OPERATIONS.contains) {return Promise.resolve([])}
const formFilters = Object.fromEntries( const formFilters = Object.fromEntries(
Object.entries(formState) Object.entries(formState)

View File

@ -14,16 +14,16 @@ export const ALLOW_FREE_CHOICE = new Set(
FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).concat(['prop_value']) FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).concat(['prop_value'])
) )
export const FILTER_TYPES = { export const FILTER_OPERATIONS = {
isNot: 'is not', isNot: 'is not',
contains: 'contains', contains: 'contains',
is: 'is' is: 'is'
}; };
export const FILTER_PREFIXES = { export const OPERATION_PREFIX = {
[FILTER_TYPES.isNot]: '!', [FILTER_OPERATIONS.isNot]: '!',
[FILTER_TYPES.contains]: '~', [FILTER_OPERATIONS.contains]: '~',
[FILTER_TYPES.is]: '' [FILTER_OPERATIONS.is]: ''
}; };
export function supportsIsNot(filterName) { export function supportsIsNot(filterName) {
@ -50,16 +50,16 @@ export function escapeFilterValue(value) {
} }
export function toFilterQuery(type, clauses) { export function toFilterQuery(type, clauses) {
const prefix = FILTER_PREFIXES[type]; const prefix = OPERATION_PREFIX[type];
const result = clauses.map(clause => escapeFilterValue(clause.value.trim())).join('|') const result = clauses.map(clause => escapeFilterValue(clause.value.trim())).join('|')
return prefix + result; return prefix + result;
} }
export function parsePrefix(rawValue) { export function parsePrefix(rawValue) {
const type = Object.keys(FILTER_PREFIXES) const type = Object.keys(OPERATION_PREFIX)
.find(type => FILTER_PREFIXES[type] === rawValue[0]) || FILTER_TYPES.is; .find(type => OPERATION_PREFIX[type] === rawValue[0]) || FILTER_OPERATIONS.is;
const value = type === FILTER_TYPES.is ? rawValue : rawValue.substring(1) const value = type === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
const values = value const values = value
.split(NON_ESCAPED_PIPE_REGEX) .split(NON_ESCAPED_PIPE_REGEX)
@ -69,14 +69,15 @@ export function parsePrefix(rawValue) {
return {type, values} return {type, values}
} }
export function parseQueryFilter(query, filter) { export function parseQueryPropsFilter(query) {
if (filter === 'props') { return Object.entries(query.filters['props']).map(([key, propVal]) => {
const rawValue = query.filters['props']
const [[_propKey, propVal]] = Object.entries(rawValue)
const {type, values} = parsePrefix(propVal) const {type, values} = parsePrefix(propVal)
const clauses = values.map(val => { return {value: val, label: val}}) const clauses = values.map(val => { return {value: val, label: val}})
return {type, clauses} return { propKey: { label: key, value: key }, type, clauses }
} else { })
}
export function parseQueryFilter(query, filter) {
const {type, values} = parsePrefix(query.filters[filter] || '') const {type, values} = parsePrefix(query.filters[filter] || '')
let labels = values let labels = values
@ -100,7 +101,6 @@ export function parseQueryFilter(query, filter) {
return {type, clauses} return {type, clauses}
} }
}
export function formatFilterGroup(filterGroup) { export function formatFilterGroup(filterGroup) {
if (filterGroup === 'utm') { if (filterGroup === 'utm') {

View File

@ -111,71 +111,11 @@ defmodule Plausible.Stats.Base do
end end
q = q =
case Query.get_filter_by_prefix(query, "event:props") do Enum.reduce(
{"event:props:" <> prop_name, {:is, value}} -> Query.get_all_filters_by_prefix(query, "event:props"),
if value == "(none)" do q,
from( &filter_by_custom_prop/2
e in q,
where: not has_key(e, :meta, ^prop_name)
) )
else
from(
e in q,
where: has_key(e, :meta, ^prop_name) and get_by_key(e, :meta, ^prop_name) == ^value
)
end
{"event:props:" <> prop_name, {:is_not, value}} ->
if value == "(none)" do
from(
e in q,
where: has_key(e, :meta, ^prop_name)
)
else
from(
e in q,
where:
not has_key(e, :meta, ^prop_name) or get_by_key(e, :meta, ^prop_name) != ^value
)
end
{"event:props:" <> prop_name, {:matches, value}} ->
regex = page_regex(value)
from(
e in q,
where:
has_key(e, :meta, ^prop_name) and
fragment("match(?, ?)", get_by_key(e, :meta, ^prop_name), ^regex)
)
{"event:props:" <> prop_name, {:member, values}} ->
none_value_included = Enum.member?(values, "(none)")
from(
e in q,
where:
(has_key(e, :meta, ^prop_name) and get_by_key(e, :meta, ^prop_name) in ^values) or
(^none_value_included and not has_key(e, :meta, ^prop_name))
)
{"event:props:" <> prop_name, {:not_member, values}} ->
none_value_included = Enum.member?(values, "(none)")
from(
e in q,
where:
(has_key(e, :meta, ^prop_name) and
get_by_key(e, :meta, ^prop_name) not in ^values) or
(^none_value_included and
has_key(e, :meta, ^prop_name) and
get_by_key(e, :meta, ^prop_name) not in ^values) or
(not (^none_value_included) and not has_key(e, :meta, ^prop_name))
)
_ ->
q
end
q q
end end
@ -545,4 +485,80 @@ defmodule Plausible.Stats.Base do
Map.get(groups, :page, []) Map.get(groups, :page, [])
} }
end end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:is, "(none)"}}, q) do
from(
e in q,
where: not has_key(e, :meta, ^prop_name)
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:is, value}}, q) do
from(
e in q,
where: has_key(e, :meta, ^prop_name) and get_by_key(e, :meta, ^prop_name) == ^value
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:is_not, "(none)"}}, q) do
from(
e in q,
where: has_key(e, :meta, ^prop_name)
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:is_not, value}}, q) do
from(
e in q,
where: not has_key(e, :meta, ^prop_name) or get_by_key(e, :meta, ^prop_name) != ^value
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:matches, value}}, q) do
regex = page_regex(value)
from(
e in q,
where:
has_key(e, :meta, ^prop_name) and
fragment("match(?, ?)", get_by_key(e, :meta, ^prop_name), ^regex)
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:member, values}}, q) do
none_value_included = Enum.member?(values, "(none)")
from(
e in q,
where:
(has_key(e, :meta, ^prop_name) and get_by_key(e, :meta, ^prop_name) in ^values) or
(^none_value_included and not has_key(e, :meta, ^prop_name))
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:not_member, values}}, q) do
none_value_included = Enum.member?(values, "(none)")
from(
e in q,
where:
(has_key(e, :meta, ^prop_name) and
get_by_key(e, :meta, ^prop_name) not in ^values) or
(^none_value_included and
has_key(e, :meta, ^prop_name) and
get_by_key(e, :meta, ^prop_name) not in ^values) or
(not (^none_value_included) and not has_key(e, :meta, ^prop_name))
)
end
defp filter_by_custom_prop({"event:props:" <> prop_name, {:matches_member, clauses}}, q) do
regexes = Enum.map(clauses, &page_regex/1)
from(
e in q,
where:
has_key(e, :meta, ^prop_name) and
fragment("arrayExists(k -> match(?, k), ?)", get_by_key(e, :meta, ^prop_name), ^regexes)
)
end
end end

View File

@ -194,6 +194,7 @@ defmodule Plausible.Stats.Breakdown do
defp include_none_result?({:member, values}), do: Enum.member?(values, "(none)") defp include_none_result?({:member, values}), do: Enum.member?(values, "(none)")
defp include_none_result?({:not_member, values}), do: !Enum.member?(values, "(none)") defp include_none_result?({:not_member, values}), do: !Enum.member?(values, "(none)")
defp include_none_result?({:matches, _}), do: false defp include_none_result?({:matches, _}), do: false
defp include_none_result?({:matches_member, _}), do: false
defp include_none_result?(_), do: true defp include_none_result?(_), do: true
defp breakdown_sessions(_, _, _, [], _), do: [] defp breakdown_sessions(_, _, _, [], _), do: []

View File

@ -134,7 +134,7 @@ defmodule Plausible.Stats.FilterSuggestions do
def filter_suggestions(site, query, "prop_key", filter_search) do def filter_suggestions(site, query, "prop_key", filter_search) do
filter_query = if filter_search == nil, do: "%", else: "%#{filter_search}%" filter_query = if filter_search == nil, do: "%", else: "%#{filter_search}%"
from(e in base_event_query(site, Query.remove_event_filters(query, [:props])), from(e in base_event_query(site, query),
array_join: meta in "meta", array_join: meta in "meta",
as: :meta, as: :meta,
select: meta.key, select: meta.key,

View File

@ -208,6 +208,12 @@ defmodule Plausible.Stats.Query do
end) end)
end end
def get_all_filters_by_prefix(query, prefix) do
Enum.filter(query.filters, fn {prop, _value} ->
String.starts_with?(prop, prefix)
end)
end
defp today(tz) do defp today(tz) do
Timex.now(tz) |> Timex.to_date() Timex.now(tz) |> Timex.to_date()
end end

View File

@ -1776,6 +1776,50 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do
} }
end end
test "Multiple event:props:* filters", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview,
browser: "Chrome",
"meta.key": ["browser"],
"meta.value": ["Chrome"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
browser: "Chrome",
"meta.key": ["browser", "prop"],
"meta.value": ["Chrome", "xyz"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
browser: "Safari",
"meta.key": ["browser", "prop"],
"meta.value": ["Safari", "target_value"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
browser: "Firefox",
"meta.key": ["browser", "prop"],
"meta.value": ["Firefox", "target_value"],
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/breakdown", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"property" => "visit:browser",
"filters" => "event:props:browser == Chrome|Safari;event:props:prop == target_value"
})
assert json_response(conn, 200) == %{
"results" => [
%{"browser" => "Safari", "visitors" => 1}
]
}
end
test "IN filter for event:props:* including (none) value", %{conn: conn, site: site} do test "IN filter for event:props:* including (none) value", %{conn: conn, site: site} do
populate_stats(site, [ populate_stats(site, [
build(:pageview, build(:pageview,

View File

@ -1007,6 +1007,89 @@ defmodule PlausibleWeb.Api.ExternalStatsController.TimeseriesTest do
assert first == %{"date" => "2021-01-01", "visitors" => 2} assert first == %{"date" => "2021-01-01", "visitors" => 2}
assert second == %{"date" => "2021-01-02", "visitors" => 1} assert second == %{"date" => "2021-01-02", "visitors" => 1}
end end
test "filter by multiple custom event properties", %{conn: conn, site: site} do
populate_stats(site, [
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["business", "Chrome"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["business", "Firefox"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["personal", "Firefox"],
timestamp: ~N[2021-01-01 00:25:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package"],
"meta.value": ["business"],
timestamp: ~N[2021-01-02 00:25:00]
)
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" =>
"event:name==Purchase;event:props:package==business;event:props:browser==Firefox"
})
%{"results" => [first, second | _rest]} = json_response(conn, 200)
assert first == %{"date" => "2021-01-01", "visitors" => 1}
assert second == %{"date" => "2021-01-02", "visitors" => 0}
end
test "filter by multiple custom event properties matching", %{conn: conn, site: site} do
populate_stats(site, [
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["business", "Chrome"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["business", "Firefox"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package", "browser"],
"meta.value": ["personal", "Safari"],
timestamp: ~N[2021-01-02 00:25:00]
),
build(:event,
name: "Purchase",
"meta.key": ["package"],
"meta.value": ["business"],
timestamp: ~N[2021-01-02 00:25:00]
)
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "event:name==Purchase;event:props:browser==F*|S*"
})
%{"results" => [first, second | _rest]} = json_response(conn, 200)
assert first == %{"date" => "2021-01-01", "visitors" => 1}
assert second == %{"date" => "2021-01-02", "visitors" => 1}
end
end end
describe "metrics" do describe "metrics" do

View File

@ -1082,6 +1082,44 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
} }
] ]
end end
test "returns prop-breakdown with multiple matching custom property filters", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foo", "1"]),
build(:pageview, "meta.key": ["key", "other"], "meta.value": ["bar", "1"]),
build(:pageview, "meta.key": ["key", "other"], "meta.value": ["bar", "2"]),
build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]),
build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foobar", "1"]),
build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foobar", "3"]),
build(:pageview)
])
filters = Jason.encode!(%{props: %{key: "~bar", other: "1"}})
conn =
get(
conn,
"/api/stats/#{site.domain}/custom-prop-values/key?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"visitors" => 1,
"name" => "bar",
"events" => 1,
"percentage" => 50.0
},
%{
"visitors" => 1,
"name" => "foobar",
"events" => 1,
"percentage" => 50.0
}
]
end
end end
describe "GET /api/stats/:domain/custom-prop-values/:prop_key - for a Growth subscription" do describe "GET /api/stats/:domain/custom-prop-values/:prop_key - for a Growth subscription" do

View File

@ -112,6 +112,85 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
] ]
end end
test "returns top pages with :matches_member filter on custom pageview props", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview,
pathname: "/1",
"meta.key": ["prop"],
"meta.value": ["bar"]
),
build(:pageview,
pathname: "/2",
"meta.key": ["prop"],
"meta.value": ["foobar"]
),
build(:pageview,
pathname: "/3",
"meta.key": ["prop"],
"meta.value": ["baar"]
),
build(:pageview,
pathname: "/4",
"meta.key": ["another"],
"meta.value": ["bar"]
),
build(:pageview, pathname: "/5"),
build(:pageview,
pathname: "/6",
"meta.key": ["prop"],
"meta.value": ["near"]
)
])
filters = Jason.encode!(%{props: %{"prop" => "~bar|nea"}})
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200) == [
%{"visitors" => 1, "name" => "/1"},
%{"visitors" => 1, "name" => "/2"},
%{"visitors" => 1, "name" => "/6"}
]
end
test "returns top pages with multiple filters on custom pageview props", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview,
pathname: "/1",
"meta.key": ["prop", "number"],
"meta.value": ["bar", "1"]
),
build(:pageview,
pathname: "/2",
"meta.key": ["prop", "number"],
"meta.value": ["bar", "2"]
),
build(:pageview,
pathname: "/3",
"meta.key": ["prop"],
"meta.value": ["bar"]
),
build(:pageview,
pathname: "/4",
"meta.key": ["number"],
"meta.value": ["bar"]
),
build(:pageview, pathname: "/5")
])
filters = Jason.encode!(%{props: %{"prop" => "bar", "number" => "1"}})
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
assert json_response(conn, 200) == [
%{"visitors" => 1, "name" => "/1"}
]
end
test "calculates bounce_rate and time_on_page with :is filter on custom pageview props", test "calculates bounce_rate and time_on_page with :is filter on custom pageview props",
%{conn: conn, site: site} do %{conn: conn, site: site} do
populate_stats(site, [ populate_stats(site, [

View File

@ -282,6 +282,63 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
] ]
end end
test "returns suggestions found in time frame", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview,
"meta.key": ["author", "logged_in"],
"meta.value": ["Uku Taht", "false"],
timestamp: ~N[2022-01-01 00:00:00]
),
build(:pageview,
"meta.key": ["dark_mode"],
"meta.value": ["true"],
timestamp: ~N[2022-01-02 00:00:00]
)
])
conn =
get(conn, "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01")
assert json_response(conn, 200) == [
%{"label" => "author", "value" => "author"},
%{"label" => "logged_in", "value" => "logged_in"}
]
end
test "returns only prop keys which exist under filter", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview,
"meta.key": ["author", "bank"],
"meta.value": ["Uku", "a"],
timestamp: ~N[2022-01-01 00:00:00]
),
build(:pageview,
"meta.key": ["author", "serial_number"],
"meta.value": ["Marko", "b"],
timestamp: ~N[2022-01-01 00:00:00]
)
])
filters = Jason.encode!(%{props: %{author: "Uku"}})
conn =
get(
conn,
"/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{"label" => "author", "value" => "author"},
%{"label" => "bank", "value" => "bank"}
]
end
test "returns suggestions for prop key based on site.allowed_event_props list", %{ test "returns suggestions for prop key based on site.allowed_event_props list", %{
conn: conn, conn: conn,
site: site site: site