mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +03:00
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:
parent
c8b25347ed
commit
1cb7982cd9
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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) {
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
85
assets/js/dashboard/stats/modals/prop-filter-row.js
Normal file
85
assets/js/dashboard/stats/modals/prop-filter-row.js
Normal 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
|
@ -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)
|
||||||
|
@ -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') {
|
||||||
|
@ -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
|
||||||
|
@ -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: []
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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, [
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user