1421 contains filter (#1799)

* small refactors and adds 'contains' to modal

* supports contains filter in the backend

* moves entry and exit page under the page filter

* prettier

* updates the CHANGELOG

* undo package-lock changes

* fixes formatting for elixir

* renames unused parameter to _

* Update changelog

* Use uppercase for constants and update type/prefix lookup
This commit is contained in:
Andrea Mazzarella 2022-03-29 07:39:16 +02:00 committed by GitHub
parent 2c25e0b468
commit 3c93a2d91b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 98 additions and 51 deletions

View File

@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
- Added `LISTEN_IP` configuration parameter plausible/analytics#1189
- The breakdown endpoint with the property query `property=event:goal` returns custom goal properties (within `props`)
- Added IPv6 Ecto support (via the environment-variable `ECTO_IPV6`)
- New filter type: `contains`, available for `page`, `entry_page`, `exit_page`
### Fixed
- UI fix where multi-line text in pills would not be underlined properly on small screens.
@ -30,6 +31,7 @@ All notable changes to this project will be documented in this file.
### Changed
- Cache the tracking script for 24 hours
- Move `entry_page` and `exit_page` to be part of the `Page` filter group
## v1.4.1

View File

@ -5,7 +5,13 @@ import classNames from 'classnames'
import { Menu, Transition } from '@headlessui/react'
import { appliedFilters, navigateToQuery, formattedFilters } from './query'
import { FILTER_GROUPS, formatFilterGroup, filterGroupForFilter } from './stats/modals/filter'
import {
FILTER_GROUPS,
formatFilterGroup,
filterGroupForFilter,
toFilterType,
valueWithoutPrefix
} from "./stats/modals/filter";
function removeFilter(key, history, query) {
const newOpts = {
@ -32,16 +38,9 @@ function clearAllFilters(history, query) {
);
}
function filterType(val) {
if (typeof(val) === 'string' && val.startsWith('!')) {
return ['is not', val.substr(1)]
}
return ['is', val]
}
function filterText(key, rawValue, query) {
const [type, value] = filterType(rawValue)
const type = toFilterType(rawValue)
const value = valueWithoutPrefix(rawValue)
if (key === "goal") {
return <>Completed goal <b>{value}</b></>

View File

@ -11,24 +11,23 @@ import * as api from '../../api'
import {apiPath, siteBasePath} from '../../util/url'
export const FILTER_GROUPS = {
'page': ['page'],
'page': ['page', 'entry_page', 'exit_page'],
'source': ['source', 'referrer'],
'location': ['country', 'region', 'city'],
'screen': ['screen'],
'browser': ['browser', 'browser_version'],
'os': ['os', 'os_version'],
'utm': ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
'entry_page': ['entry_page'],
'exit_page': ['exit_page'],
'goal': ['goal']
}
function getFormState(filterGroup, query) {
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
const filterValue = query.filters[filter] || ''
let filterName = filterValue
const rawFilterValue = query.filters[filter] || ''
const type = toFilterType(rawFilterValue)
const filterValue = valueWithoutPrefix(rawFilterValue)
const type = filterValue[0] === '!' ? 'is_not' : 'is'
let filterName = filterValue
if (filter === 'country' && filterValue !== '') {
filterName = (new URLSearchParams(window.location.search)).get('country_name')
@ -43,6 +42,38 @@ function getFormState(filterGroup, query) {
}, {})
}
const FILTER_TYPES = {
isNot: 'is not',
contains: 'contains',
is: 'is'
};
const FILTER_PREFIXES = {
[FILTER_TYPES.isNot]: '!',
[FILTER_TYPES.contains]: '~',
[FILTER_TYPES.is]: ''
};
export function toFilterType(value) {
return Object.keys(FILTER_PREFIXES)
.find(type => FILTER_PREFIXES[type] === value[0]) || FILTER_TYPES.is;
}
export function valueWithoutPrefix(value) {
return [FILTER_TYPES.isNot, FILTER_TYPES.contains].includes(toFilterType(value))
? value.substring(1)
: value;
}
function toFilterQuery(value, type) {
const prefix = FILTER_PREFIXES[type];
return prefix + value.trim();
}
function supportsContains(filterName) {
return ['page', 'entry_page', 'exit_page'].includes(filterName)
}
function withIndefiniteArticle(word) {
if (word.startsWith('UTM')) {
return `a ${ word}`
@ -113,10 +144,7 @@ class FilterModal extends React.Component {
if (filterKey === 'region') { res.push({filter: 'region_name', value: name}) }
if (filterKey === 'city') { res.push({filter: 'city_name', value: name}) }
let finalFilterValue = value
finalFilterValue = (type === 'is_not' ? '!' : '') + finalFilterValue.trim()
res.push({filter: filterKey, value: finalFilterValue})
res.push({filter: filterKey, value: toFilterQuery(value, type)})
return res
}, [])
@ -239,34 +267,9 @@ class FilterModal extends React.Component {
className="z-10 origin-top-left absolute left-0 mt-2 w-24 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<span
onClick={() => this.setFilterType(filterName, 'is')}
className={classNames(
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-200',
'cursor-pointer block px-4 py-2 text-sm'
)}
>
is
</span>
)}
</Menu.Item>
{ filterName !== 'goal' && (
<Menu.Item>
{({ active }) => (
<span
onClick={() => this.setFilterType(filterName, 'is_not')}
className={classNames(
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-200',
'cursor-pointer block px-4 py-2 text-sm'
)}
>
is not
</span>
)}
</Menu.Item>
)}
{ this.renderTypeItem(filterName, FILTER_TYPES.is, true) }
{ this.renderTypeItem(filterName, FILTER_TYPES.isNot, filterName !== 'goal') }
{ this.renderTypeItem(filterName, FILTER_TYPES.contains, supportsContains(filterName)) }
</div>
</Menu.Items>
</Transition>
@ -276,9 +279,29 @@ class FilterModal extends React.Component {
)
}
renderBody() {
const { selectedFilterGroup, query } = this.state;
const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
renderTypeItem(filterName, type, shouldDisplay) {
return (
shouldDisplay && (
<Menu.Item>
{({ active }) => (
<span
onClick={() => this.setFilterType(filterName, type)}
className={classNames(
active ? "bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100" : "text-gray-700 dark:text-gray-200",
"cursor-pointer block px-4 py-2 text-sm"
)}
>
{ type }
</span>
)}
</Menu.Item>
)
);
}
renderBody() {
const { selectedFilterGroup, query } = this.state;
const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
return (
<>

View File

@ -70,6 +70,10 @@ defmodule Plausible.Stats.Filters do
end
end
defp filter_value(_, "~" <> val) do
{:matches, "**" <> val <> "**"}
end
defp filter_value(key, val) do
if String.contains?(key, ["page", "goal"]) && String.match?(val, ~r/\*/) do
{:matches, val}

View File

@ -408,6 +408,25 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
end
test "contains (~) filter", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/some-blog-post"),
build(:pageview, pathname: "/blog/post1"),
build(:pageview, pathname: "/another/post")
])
filters = Jason.encode!(%{page: "~blog"})
conn =
get(
conn,
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
)
res = json_response(conn, 200)
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
end
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, screen_size: "Desktop"),