mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +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.
|
||||
|
||||
### Added
|
||||
- Allow filtering by multiple custom properties
|
||||
- Wildcard and member filtering on the Stats API `event:goal` property
|
||||
- Allow filtering with `contains`/`matches` operator for custom properties
|
||||
- Add `referrers.csv` to CSV export
|
||||
|
@ -91,8 +91,10 @@ export default function PlausibleCombobox(props) {
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabled(option) {
|
||||
return props.values.some((val) => val.value === option.value)
|
||||
function isOptionDisabled(option) {
|
||||
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) {
|
||||
@ -161,7 +163,7 @@ export default function PlausibleCombobox(props) {
|
||||
searchRef.current.focus()
|
||||
}
|
||||
}, [props.values.length === 0])
|
||||
|
||||
|
||||
const searchBoxClass = 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm'
|
||||
|
||||
const containerClass = classNames('relative w-full', {
|
||||
@ -217,15 +219,15 @@ export default function PlausibleCombobox(props) {
|
||||
}
|
||||
|
||||
function renderDropDownContent() {
|
||||
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isDisabled(option))
|
||||
const matchesFound = visibleOptions.length > 0 && visibleOptions.some(option => !isOptionDisabled(option))
|
||||
|
||||
if (loading) {
|
||||
return <div className="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300">Loading options...</div>
|
||||
}
|
||||
|
||||
|
||||
if (matchesFound) {
|
||||
return visibleOptions
|
||||
.filter(option => !isDisabled(option))
|
||||
.filter(option => !isOptionDisabled(option))
|
||||
.map((option, i) => {
|
||||
const text = option.freeChoice ? `Filter by '${option.label}'` : option.label
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { FILTER_TYPES } from "../util/filters";
|
||||
import { FILTER_OPERATIONS } from "../util/filters";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
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"
|
||||
>
|
||||
<div className="py-1">
|
||||
{renderTypeItem(FILTER_TYPES.is, true)}
|
||||
{renderTypeItem(FILTER_TYPES.isNot, supportsIsNot(filterName))}
|
||||
{renderTypeItem(FILTER_TYPES.contains, isFreeChoiceFilter(filterName))}
|
||||
{renderTypeItem(FILTER_OPERATIONS.is, true)}
|
||||
{renderTypeItem(FILTER_OPERATIONS.isNot, supportsIsNot(filterName))}
|
||||
{renderTypeItem(FILTER_OPERATIONS.contains, isFreeChoiceFilter(filterName))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
@ -10,12 +10,23 @@ import {
|
||||
formatFilterGroup,
|
||||
filterGroupForFilter,
|
||||
parseQueryFilter,
|
||||
parseQueryPropsFilter,
|
||||
formattedFilters
|
||||
} from "./util/filters";
|
||||
|
||||
function removeFilter(key, history, query) {
|
||||
const newOpts = {
|
||||
[key]: false
|
||||
function removeFilter(filterType, key, history, query) {
|
||||
const newOpts = {}
|
||||
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 === 'region') { newOpts.region_labels = false }
|
||||
@ -37,34 +48,38 @@ function clearAllFilters(history, query) {
|
||||
);
|
||||
}
|
||||
|
||||
function filterText(key, _rawValue, query) {
|
||||
const {type, clauses} = parseQueryFilter(query, key)
|
||||
function filterText(filterType, key, query) {
|
||||
const formattedFilter = formattedFilters[key]
|
||||
|
||||
if (key === "props") {
|
||||
const [[propKey, _propValue]] = Object.entries(query.filters['props'])
|
||||
return <>Property <b>{propKey}</b> {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
|
||||
if (filterType === "props") {
|
||||
const { propKey, clauses, type } = parseQueryPropsFilter(query).find((filter) => filter.propKey.value === key)
|
||||
return <>Property <b>{propKey.label}</b> {type} {clauses.map(({label}) => <b key={label}>{label}</b>).reduce((prev, curr) => [prev, ' or ', curr])} </>
|
||||
} 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])} </>
|
||||
}
|
||||
|
||||
throw new Error(`Unknown filter: ${key}`)
|
||||
}
|
||||
|
||||
function renderDropdownFilter(site, history, [key, value], query) {
|
||||
function renderDropdownFilter(site, history, { key, value, filterType }, query) {
|
||||
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}>
|
||||
<Link
|
||||
title={`Edit filter: ${formattedFilters[key]}`}
|
||||
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${filterGroupForFilter(key)}`, search: window.location.search }}
|
||||
title={`Edit filter: ${formattedFilters[filterType]}`}
|
||||
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${filterGroupForFilter(filterType)}`, search: window.location.search }}
|
||||
className="group flex w-full justify-between items-center"
|
||||
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" />
|
||||
</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" />
|
||||
</b>
|
||||
</div>
|
||||
@ -202,13 +217,21 @@ class Filters extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
renderListFilter(history, [key, value], query) {
|
||||
renderListFilter(history, { key, value, filterType }, query) {
|
||||
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">
|
||||
<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 }}>
|
||||
<span className="inline-block max-w-2xs md:max-w-xs truncate">{filterText(key, value, query)}</span>
|
||||
<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[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>
|
||||
<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" />
|
||||
</span>
|
||||
</span>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Link, withRouter } from 'react-router-dom'
|
||||
import {nowForSite} from './util/date'
|
||||
import { nowForSite } from './util/date'
|
||||
import * as storage from './util/storage'
|
||||
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled,getStoredMatchDayOfWeek } from './comparison-input'
|
||||
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
@ -65,9 +65,13 @@ export function parseQuery(querystring, site) {
|
||||
}
|
||||
|
||||
export function appliedFilters(query) {
|
||||
return Object.keys(query.filters)
|
||||
.map((key) => [key, query.filters[key]])
|
||||
.filter(([_key, value]) => !!value);
|
||||
const propKeys = Object.entries(query.filters.props || {})
|
||||
.map(([key, value]) => ({ key, value, filterType: 'props' }))
|
||||
|
||||
return Object.entries(query.filters)
|
||||
.map(([key, value]) => ({ key, value, filterType: key }))
|
||||
.filter(({ key, value }) => key !== 'props' && !!value)
|
||||
.concat(propKeys)
|
||||
}
|
||||
|
||||
function generateQueryString(data) {
|
||||
@ -122,7 +126,7 @@ class QueryLink extends React.Component {
|
||||
const QueryLinkWithRouter = withRouter(QueryLink)
|
||||
export { QueryLinkWithRouter as QueryLink };
|
||||
|
||||
function QueryButton({history, query, to, disabled, className, children, onClick}) {
|
||||
function QueryButton({ history, query, to, disabled, className, children, onClick }) {
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
|
@ -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 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 (
|
||||
<div className="w-full mt-4">
|
||||
<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>
|
||||
{propKey && renderBreakdown()}
|
||||
</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 Combobox from '../../components/combobox'
|
||||
import FilterTypeSelector from "../../components/filter-type-selector";
|
||||
import { FILTER_TYPES } from "../../util/filters";
|
||||
import { FILTER_OPERATIONS } from "../../util/filters";
|
||||
import { parseQuery } from '../../query'
|
||||
import * as api from '../../api'
|
||||
import { apiPath, siteBasePath } from '../../util/url'
|
||||
import { toFilterQuery, parseQueryFilter } from '../../util/filters';
|
||||
import { siteBasePath } from '../../util/url'
|
||||
import { toFilterQuery, parseQueryPropsFilter } from '../../util/filters';
|
||||
import { shouldIgnoreKeypress } from '../../keybinding';
|
||||
import PropFilterRow from './prop-filter-row';
|
||||
|
||||
function getFormState(query) {
|
||||
const rawValue = query.filters['props']
|
||||
if (rawValue) {
|
||||
const [[propKey, _propValue]] = Object.entries(rawValue)
|
||||
const {type, clauses} = parseQueryFilter(query, 'props')
|
||||
if (query.filters['props']) {
|
||||
const values = Object.fromEntries(parseQueryPropsFilter(query, 'props').map((value, index) => [index, value]))
|
||||
|
||||
return {
|
||||
prop_key: {value: propKey, label: propKey},
|
||||
prop_value: { type: type, clauses: clauses }
|
||||
entries: Object.keys(values).sort(),
|
||||
values
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
entries: [0],
|
||||
values: {
|
||||
0: {
|
||||
propKey: null,
|
||||
type: FILTER_OPERATIONS.is,
|
||||
clauses: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prop_key: null,
|
||||
prop_value: { type: FILTER_TYPES.is, clauses: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,79 +34,78 @@ function PropFilterModal(props) {
|
||||
const query = parseQuery(props.location.search, props.site)
|
||||
const [formState, setFormState] = useState(getFormState(query))
|
||||
|
||||
function fetchPropKeyOptions() {
|
||||
return (input) => {
|
||||
return api.get(apiPath(props.site, "/suggestions/prop_key"), query, { q: input.trim() })
|
||||
}
|
||||
}
|
||||
const selectedPropKeys = useMemo(() => Object.values(formState.values).map((value) => value.propKey), [formState])
|
||||
|
||||
const fetchPropValueOptions = useCallback(() => {
|
||||
return (input) => {
|
||||
if (formState.prop_value?.type === FILTER_TYPES.contains) {
|
||||
return Promise.resolve([])
|
||||
function onPropKeySelect(id, selectedOptions) {
|
||||
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0]
|
||||
setFormState(prevState => ({
|
||||
...prevState,
|
||||
values: {
|
||||
...prevState.values,
|
||||
[id]: {
|
||||
...prevState.values[id],
|
||||
propKey: newPropKey,
|
||||
clauses: []
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
setFormState(prevState => ({
|
||||
prop_key: newPropKey,
|
||||
prop_value: { type: prevState.prop_value.type, clauses: [] }
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function onPropValueSelect() {
|
||||
return (selection) => {
|
||||
setFormState(prevState => ({
|
||||
...prevState, prop_value: { ...prevState.prop_value, clauses: selection }
|
||||
}))
|
||||
}
|
||||
function onPropValueSelect(id, selection) {
|
||||
setFormState(prevState => ({
|
||||
...prevState,
|
||||
values: {
|
||||
...prevState.values,
|
||||
[id]: {
|
||||
...prevState.values[id],
|
||||
clauses: selection
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function onFilterTypeSelect() {
|
||||
return (newType) => {
|
||||
setFormState(prevState => ({
|
||||
...prevState, prop_value: { ...prevState.prop_value, type: newType }
|
||||
}))
|
||||
}
|
||||
function onFilterTypeSelect(id, newType) {
|
||||
setFormState(prevState => ({
|
||||
...prevState,
|
||||
values: {
|
||||
...prevState.values,
|
||||
[id]: {
|
||||
...prevState.values[id],
|
||||
type: newType
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function selectedFilterType() {
|
||||
return formState.prop_value.type
|
||||
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 renderFilterInputs() {
|
||||
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 onPropDelete(id) {
|
||||
setFormState(prevState => {
|
||||
const entries = prevState.entries.filter((entry) => entry != id)
|
||||
const values = {...prevState.values}
|
||||
delete values[id]
|
||||
|
||||
return { entries, values }
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -112,8 +113,11 @@ function PropFilterModal(props) {
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const filterString = JSON.stringify({ [formState.prop_key.value]: toFilterQuery(formState.prop_value.type, formState.prop_value.clauses) })
|
||||
selectFiltersAndCloseModal(filterString)
|
||||
const formFilters = Object.fromEntries(
|
||||
Object.entries(formState.values)
|
||||
.map(([_, {propKey, type, clauses}]) => [propKey.value, toFilterQuery(type, clauses)])
|
||||
)
|
||||
selectFiltersAndCloseModal(JSON.stringify(formFilters))
|
||||
}
|
||||
|
||||
function selectFiltersAndCloseModal(filterString) {
|
||||
@ -150,7 +154,27 @@ function PropFilterModal(props) {
|
||||
<div className="mt-4 border-b border-gray-300"></div>
|
||||
<main className="modal__content">
|
||||
<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">
|
||||
<button
|
||||
@ -165,7 +189,7 @@ function PropFilterModal(props) {
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 button px-4 flex bg-red-500 dark:bg-red-500 hover:bg-red-600 dark:hover:bg-red-700 items-center"
|
||||
onClick={() => {selectFiltersAndCloseModal(null)}}
|
||||
onClick={() => { selectFiltersAndCloseModal(null) }}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
Remove filter
|
||||
|
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 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 * as api from '../../api'
|
||||
import { apiPath, siteBasePath } from '../../util/url'
|
||||
@ -32,7 +32,7 @@ class RegularFilterModal extends React.Component {
|
||||
super(props)
|
||||
const query = parseQuery(props.location.search, props.site)
|
||||
const formState = getFormState(props.filterGroup, query)
|
||||
|
||||
|
||||
this.handleKeydown = this.handleKeydown.bind(this)
|
||||
this.state = { query, formState }
|
||||
}
|
||||
@ -92,7 +92,7 @@ class RegularFilterModal extends React.Component {
|
||||
fetchOptions(filter) {
|
||||
return (input) => {
|
||||
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(
|
||||
Object.entries(formState)
|
||||
|
@ -14,16 +14,16 @@ export const ALLOW_FREE_CHOICE = new Set(
|
||||
FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).concat(['prop_value'])
|
||||
)
|
||||
|
||||
export const FILTER_TYPES = {
|
||||
export const FILTER_OPERATIONS = {
|
||||
isNot: 'is not',
|
||||
contains: 'contains',
|
||||
is: 'is'
|
||||
};
|
||||
|
||||
export const FILTER_PREFIXES = {
|
||||
[FILTER_TYPES.isNot]: '!',
|
||||
[FILTER_TYPES.contains]: '~',
|
||||
[FILTER_TYPES.is]: ''
|
||||
export const OPERATION_PREFIX = {
|
||||
[FILTER_OPERATIONS.isNot]: '!',
|
||||
[FILTER_OPERATIONS.contains]: '~',
|
||||
[FILTER_OPERATIONS.is]: ''
|
||||
};
|
||||
|
||||
export function supportsIsNot(filterName) {
|
||||
@ -50,16 +50,16 @@ export function escapeFilterValue(value) {
|
||||
}
|
||||
|
||||
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('|')
|
||||
return prefix + result;
|
||||
}
|
||||
|
||||
export function parsePrefix(rawValue) {
|
||||
const type = Object.keys(FILTER_PREFIXES)
|
||||
.find(type => FILTER_PREFIXES[type] === rawValue[0]) || FILTER_TYPES.is;
|
||||
const type = Object.keys(OPERATION_PREFIX)
|
||||
.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
|
||||
.split(NON_ESCAPED_PIPE_REGEX)
|
||||
@ -69,37 +69,37 @@ export function parsePrefix(rawValue) {
|
||||
return {type, values}
|
||||
}
|
||||
|
||||
export function parseQueryFilter(query, filter) {
|
||||
if (filter === 'props') {
|
||||
const rawValue = query.filters['props']
|
||||
const [[_propKey, propVal]] = Object.entries(rawValue)
|
||||
export function parseQueryPropsFilter(query) {
|
||||
return Object.entries(query.filters['props']).map(([key, propVal]) => {
|
||||
const {type, values} = parsePrefix(propVal)
|
||||
const clauses = values.map(val => { return {value: val, label: val}})
|
||||
return {type, clauses}
|
||||
} else {
|
||||
const {type, values} = parsePrefix(query.filters[filter] || '')
|
||||
return { propKey: { label: key, value: key }, type, clauses }
|
||||
})
|
||||
}
|
||||
|
||||
let labels = values
|
||||
export function parseQueryFilter(query, filter) {
|
||||
const {type, values} = parsePrefix(query.filters[filter] || '')
|
||||
|
||||
if (filter === 'country' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('country_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
let labels = values
|
||||
|
||||
if (filter === 'region' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('region_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
if (filter === 'city' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('city_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
const clauses = values.map((value, index) => { return {value, label: labels[index]}})
|
||||
|
||||
return {type, clauses}
|
||||
if (filter === 'country' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('country_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
if (filter === 'region' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('region_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
if (filter === 'city' && values.length > 0) {
|
||||
const rawLabel = (new URLSearchParams(window.location.search)).get('city_labels') || ''
|
||||
labels = rawLabel.split('|').filter(label => !!label)
|
||||
}
|
||||
|
||||
const clauses = values.map((value, index) => { return {value, label: labels[index]}})
|
||||
|
||||
return {type, clauses}
|
||||
}
|
||||
|
||||
export function formatFilterGroup(filterGroup) {
|
||||
|
@ -111,71 +111,11 @@ defmodule Plausible.Stats.Base do
|
||||
end
|
||||
|
||||
q =
|
||||
case Query.get_filter_by_prefix(query, "event:props") do
|
||||
{"event:props:" <> prop_name, {:is, value}} ->
|
||||
if value == "(none)" do
|
||||
from(
|
||||
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
|
||||
Enum.reduce(
|
||||
Query.get_all_filters_by_prefix(query, "event:props"),
|
||||
q,
|
||||
&filter_by_custom_prop/2
|
||||
)
|
||||
|
||||
q
|
||||
end
|
||||
@ -545,4 +485,80 @@ defmodule Plausible.Stats.Base do
|
||||
Map.get(groups, :page, [])
|
||||
}
|
||||
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
|
||||
|
@ -194,6 +194,7 @@ defmodule Plausible.Stats.Breakdown do
|
||||
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?({:matches, _}), do: false
|
||||
defp include_none_result?({:matches_member, _}), do: false
|
||||
defp include_none_result?(_), do: true
|
||||
|
||||
defp breakdown_sessions(_, _, _, [], _), do: []
|
||||
|
@ -134,7 +134,7 @@ defmodule Plausible.Stats.FilterSuggestions do
|
||||
def filter_suggestions(site, query, "prop_key", filter_search) do
|
||||
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",
|
||||
as: :meta,
|
||||
select: meta.key,
|
||||
|
@ -208,6 +208,12 @@ defmodule Plausible.Stats.Query do
|
||||
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
|
||||
Timex.now(tz) |> Timex.to_date()
|
||||
end
|
||||
|
@ -1776,6 +1776,50 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do
|
||||
}
|
||||
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
|
||||
populate_stats(site, [
|
||||
build(:pageview,
|
||||
|
@ -1007,6 +1007,89 @@ defmodule PlausibleWeb.Api.ExternalStatsController.TimeseriesTest do
|
||||
assert first == %{"date" => "2021-01-01", "visitors" => 2}
|
||||
assert second == %{"date" => "2021-01-02", "visitors" => 1}
|
||||
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
|
||||
|
||||
describe "metrics" do
|
||||
|
@ -1082,6 +1082,44 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
|
||||
}
|
||||
]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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",
|
||||
%{conn: conn, site: site} do
|
||||
populate_stats(site, [
|
||||
|
@ -282,6 +282,63 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
|
||||
]
|
||||
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", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
|
Loading…
Reference in New Issue
Block a user