mirror of
https://github.com/plausible/analytics.git
synced 2024-12-24 10:02:10 +03:00
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:
parent
2c25e0b468
commit
3c93a2d91b
@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Added `LISTEN_IP` configuration parameter plausible/analytics#1189
|
- Added `LISTEN_IP` configuration parameter plausible/analytics#1189
|
||||||
- The breakdown endpoint with the property query `property=event:goal` returns custom goal properties (within `props`)
|
- 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`)
|
- Added IPv6 Ecto support (via the environment-variable `ECTO_IPV6`)
|
||||||
|
- New filter type: `contains`, available for `page`, `entry_page`, `exit_page`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- UI fix where multi-line text in pills would not be underlined properly on small screens.
|
- 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
|
### Changed
|
||||||
- Cache the tracking script for 24 hours
|
- Cache the tracking script for 24 hours
|
||||||
|
- Move `entry_page` and `exit_page` to be part of the `Page` filter group
|
||||||
|
|
||||||
## v1.4.1
|
## v1.4.1
|
||||||
|
|
||||||
|
@ -5,7 +5,13 @@ import classNames from 'classnames'
|
|||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
|
|
||||||
import { appliedFilters, navigateToQuery, formattedFilters } from './query'
|
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) {
|
function removeFilter(key, history, query) {
|
||||||
const newOpts = {
|
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) {
|
function filterText(key, rawValue, query) {
|
||||||
const [type, value] = filterType(rawValue)
|
const type = toFilterType(rawValue)
|
||||||
|
const value = valueWithoutPrefix(rawValue)
|
||||||
|
|
||||||
if (key === "goal") {
|
if (key === "goal") {
|
||||||
return <>Completed goal <b>{value}</b></>
|
return <>Completed goal <b>{value}</b></>
|
||||||
|
@ -11,24 +11,23 @@ import * as api from '../../api'
|
|||||||
import {apiPath, siteBasePath} from '../../util/url'
|
import {apiPath, siteBasePath} from '../../util/url'
|
||||||
|
|
||||||
export const FILTER_GROUPS = {
|
export const FILTER_GROUPS = {
|
||||||
'page': ['page'],
|
'page': ['page', 'entry_page', 'exit_page'],
|
||||||
'source': ['source', 'referrer'],
|
'source': ['source', 'referrer'],
|
||||||
'location': ['country', 'region', 'city'],
|
'location': ['country', 'region', 'city'],
|
||||||
'screen': ['screen'],
|
'screen': ['screen'],
|
||||||
'browser': ['browser', 'browser_version'],
|
'browser': ['browser', 'browser_version'],
|
||||||
'os': ['os', 'os_version'],
|
'os': ['os', 'os_version'],
|
||||||
'utm': ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
|
'utm': ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
|
||||||
'entry_page': ['entry_page'],
|
|
||||||
'exit_page': ['exit_page'],
|
|
||||||
'goal': ['goal']
|
'goal': ['goal']
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormState(filterGroup, query) {
|
function getFormState(filterGroup, query) {
|
||||||
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
|
return FILTER_GROUPS[filterGroup].reduce((result, filter) => {
|
||||||
const filterValue = query.filters[filter] || ''
|
const rawFilterValue = query.filters[filter] || ''
|
||||||
let filterName = filterValue
|
const type = toFilterType(rawFilterValue)
|
||||||
|
const filterValue = valueWithoutPrefix(rawFilterValue)
|
||||||
|
|
||||||
const type = filterValue[0] === '!' ? 'is_not' : 'is'
|
let filterName = filterValue
|
||||||
|
|
||||||
if (filter === 'country' && filterValue !== '') {
|
if (filter === 'country' && filterValue !== '') {
|
||||||
filterName = (new URLSearchParams(window.location.search)).get('country_name')
|
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) {
|
function withIndefiniteArticle(word) {
|
||||||
if (word.startsWith('UTM')) {
|
if (word.startsWith('UTM')) {
|
||||||
return `a ${ word}`
|
return `a ${ word}`
|
||||||
@ -113,10 +144,7 @@ class FilterModal extends React.Component {
|
|||||||
if (filterKey === 'region') { res.push({filter: 'region_name', value: name}) }
|
if (filterKey === 'region') { res.push({filter: 'region_name', value: name}) }
|
||||||
if (filterKey === 'city') { res.push({filter: 'city_name', value: name}) }
|
if (filterKey === 'city') { res.push({filter: 'city_name', value: name}) }
|
||||||
|
|
||||||
let finalFilterValue = value
|
res.push({filter: filterKey, value: toFilterQuery(value, type)})
|
||||||
finalFilterValue = (type === 'is_not' ? '!' : '') + finalFilterValue.trim()
|
|
||||||
|
|
||||||
res.push({filter: filterKey, value: finalFilterValue})
|
|
||||||
return res
|
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"
|
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">
|
<div className="py-1">
|
||||||
<Menu.Item>
|
{ this.renderTypeItem(filterName, FILTER_TYPES.is, true) }
|
||||||
{({ active }) => (
|
{ this.renderTypeItem(filterName, FILTER_TYPES.isNot, filterName !== 'goal') }
|
||||||
<span
|
{ this.renderTypeItem(filterName, FILTER_TYPES.contains, supportsContains(filterName)) }
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -276,6 +279,26 @@ class FilterModal extends React.Component {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
renderBody() {
|
||||||
const { selectedFilterGroup, query } = this.state;
|
const { selectedFilterGroup, query } = this.state;
|
||||||
const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
|
const showClear = FILTER_GROUPS[selectedFilterGroup].some((filterName) => query.filters[filterName])
|
||||||
|
@ -70,6 +70,10 @@ defmodule Plausible.Stats.Filters do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp filter_value(_, "~" <> val) do
|
||||||
|
{:matches, "**" <> val <> "**"}
|
||||||
|
end
|
||||||
|
|
||||||
defp filter_value(key, val) do
|
defp filter_value(key, val) do
|
||||||
if String.contains?(key, ["page", "goal"]) && String.match?(val, ~r/\*/) do
|
if String.contains?(key, ["page", "goal"]) && String.match?(val, ~r/\*/) do
|
||||||
{:matches, val}
|
{:matches, val}
|
||||||
|
@ -408,6 +408,25 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
end
|
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
|
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, screen_size: "Desktop"),
|
build(:pageview, screen_size: "Desktop"),
|
||||||
|
Loading…
Reference in New Issue
Block a user