Skip to content

Commit 1b95433

Browse files
authored
Support contains/matches for custom properties (#3687)
* Allow `matches` operator to work in BE for custom props Note: No FE support yet, needs further testing * feat: allow choosing `contains` for property filters in the UI * no autocomplete on prop values if `contains` for consistency * CHANGELOG.md * Fix: Handle (none) property in property breakdowns when using matching When matching we should always exclude (none)
1 parent 0a124e6 commit 1b95433

File tree

7 files changed

+104
-4
lines changed

7 files changed

+104
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
All notable changes to this project will be documented in this file.
33

44
### Added
5+
- Allow filtering with `contains`/`matches` operator for custom properties
56
- Add `referrers.csv` to CSV export
67
- Add a new Properties section in the dashboard to break down by custom properties
78
- Add `custom_props.csv` to CSV export (almost the same as the old `prop_breakdown.csv`, but has different column headers, and includes props for pageviews too, not only custom events)

assets/js/dashboard/stats/modals/prop-filter-modal.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,15 @@ function PropFilterModal(props) {
4040

4141
const fetchPropValueOptions = useCallback(() => {
4242
return (input) => {
43+
if (formState.prop_value?.type === FILTER_TYPES.contains) {
44+
return Promise.resolve([])
45+
}
46+
4347
const propKey = formState.prop_key?.value
4448
const updatedQuery = { ...query, filters: { ...query.filters, props: {[propKey]: '!(none)'} } }
4549
return api.get(apiPath(props.site, "/suggestions/prop_value"), updatedQuery, { q: input.trim() })
4650
}
47-
}, [formState.prop_key])
51+
}, [formState.prop_key, formState.prop_value])
4852

4953
function onPropKeySelect() {
5054
return (selectedOptions) => {
@@ -86,7 +90,14 @@ function PropFilterModal(props) {
8690
<FilterTypeSelector isDisabled={!formState.prop_key} forFilter={'prop_value'} onSelect={onFilterTypeSelect()} selectedType={selectedFilterType()} />
8791
</div>
8892
<div className="col-span-4">
89-
<Combobox isDisabled={!formState.prop_key} fetchOptions={fetchPropValueOptions()} values={formState.prop_value.clauses} onSelect={onPropValueSelect()} placeholder={'Value'} />
93+
<Combobox
94+
isDisabled={!formState.prop_key}
95+
fetchOptions={fetchPropValueOptions()}
96+
values={formState.prop_value.clauses}
97+
onSelect={onPropValueSelect()}
98+
placeholder={'Value'}
99+
freeChoice={selectedFilterType() == FILTER_TYPES.contains}
100+
/>
90101
</div>
91102
</div>
92103
)
@@ -167,4 +178,4 @@ function PropFilterModal(props) {
167178
)
168179
}
169180

170-
export default withRouter(PropFilterModal)
181+
export default withRouter(PropFilterModal)

assets/js/dashboard/util/filters.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const FILTER_GROUPS = {
1010
'props': ['prop_key', 'prop_value']
1111
}
1212

13+
export const ALLOW_FREE_CHOICE = new Set(
14+
FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).concat(['prop_value'])
15+
)
16+
1317
export const FILTER_TYPES = {
1418
isNot: 'is not',
1519
contains: 'contains',
@@ -27,7 +31,7 @@ export function supportsIsNot(filterName) {
2731
}
2832

2933
export function isFreeChoiceFilter(filterName) {
30-
return FILTER_GROUPS['page'].concat(FILTER_GROUPS['utm']).includes(filterName)
34+
return ALLOW_FREE_CHOICE.has(filterName)
3135
}
3236

3337
// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means

lib/plausible/stats/base.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ defmodule Plausible.Stats.Base do
142142
)
143143
end
144144

145+
{"event:props:" <> prop_name, {:matches, value}} ->
146+
regex = page_regex(value)
147+
148+
from(
149+
e in q,
150+
left_array_join: meta in "meta",
151+
as: :meta,
152+
where: meta.key == ^prop_name and fragment("match(?, ?)", meta.value, ^regex)
153+
)
154+
145155
{"event:props:" <> prop_name, {:member, values}} ->
146156
none_value_included = Enum.member?(values, "(none)")
147157

lib/plausible/stats/breakdown.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ defmodule Plausible.Stats.Breakdown do
189189
defp include_none_result?({:is_not, "(none)"}), do: false
190190
defp include_none_result?({:member, values}), do: Enum.member?(values, "(none)")
191191
defp include_none_result?({:not_member, values}), do: !Enum.member?(values, "(none)")
192+
defp include_none_result?({:matches, _}), do: false
192193
defp include_none_result?(_), do: true
193194

194195
defp breakdown_sessions(_, _, _, [], _), do: []

test/plausible_web/controllers/api/stats_controller/custom_prop_breakdown_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,42 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
10461046
}
10471047
]
10481048
end
1049+
1050+
test "returns prop-breakdown with a prop_value matching filter", %{
1051+
conn: conn,
1052+
site: site
1053+
} do
1054+
populate_stats(site, [
1055+
build(:pageview, "meta.key": ["key"], "meta.value": ["foo"]),
1056+
build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]),
1057+
build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]),
1058+
build(:pageview, "meta.key": ["key"], "meta.value": ["foobar"]),
1059+
build(:pageview)
1060+
])
1061+
1062+
filters = Jason.encode!(%{props: %{key: "~bar"}})
1063+
1064+
conn =
1065+
get(
1066+
conn,
1067+
"/api/stats/#{site.domain}/custom-prop-values/key?period=day&filters=#{filters}"
1068+
)
1069+
1070+
assert json_response(conn, 200) == [
1071+
%{
1072+
"visitors" => 2,
1073+
"name" => "bar",
1074+
"events" => 2,
1075+
"percentage" => 66.7
1076+
},
1077+
%{
1078+
"visitors" => 1,
1079+
"name" => "foobar",
1080+
"events" => 1,
1081+
"percentage" => 33.3
1082+
}
1083+
]
1084+
end
10491085
end
10501086

10511087
describe "GET /api/stats/:domain/custom-prop-values/:prop_key - for a Growth subscription" do

test/plausible_web/controllers/api/stats_controller/pages_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,43 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
7575
]
7676
end
7777

78+
test "returns top pages with :matches filter on custom pageview props", %{
79+
conn: conn,
80+
site: site
81+
} do
82+
populate_stats(site, [
83+
build(:pageview,
84+
pathname: "/1",
85+
"meta.key": ["prop"],
86+
"meta.value": ["bar"]
87+
),
88+
build(:pageview,
89+
pathname: "/2",
90+
"meta.key": ["prop"],
91+
"meta.value": ["foobar"]
92+
),
93+
build(:pageview,
94+
pathname: "/3",
95+
"meta.key": ["prop"],
96+
"meta.value": ["baar"]
97+
),
98+
build(:pageview,
99+
pathname: "/4",
100+
"meta.key": ["another"],
101+
"meta.value": ["bar"]
102+
),
103+
build(:pageview, pathname: "/5")
104+
])
105+
106+
filters = Jason.encode!(%{props: %{"prop" => "~bar"}})
107+
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}")
108+
109+
assert json_response(conn, 200) == [
110+
%{"visitors" => 1, "name" => "/1"},
111+
%{"visitors" => 1, "name" => "/2"}
112+
]
113+
end
114+
78115
test "calculates bounce_rate and time_on_page with :is filter on custom pageview props",
79116
%{conn: conn, site: site} do
80117
populate_stats(site, [

0 commit comments

Comments
 (0)