mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +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
|
||||
- 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
|
||||
|
||||
|
@ -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></>
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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}
|
||||
|
@ -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"),
|
||||
|
Loading…
Reference in New Issue
Block a user