Skip to content

Commit 90a2c5d

Browse files
authored
Flatten ?filters=(...)&labels=(...) to ?f=...&f=...&l=...&l=... (#4810)
* Serialize filters and labels as ?f=is,page,/a,/b&f=... * Update changelog * Refactor not to use URLSearchParams, because it led to double decoding * Handle empty search string and badly formed search strings * Declare repeating characters as constant * Introduce version names
1 parent 558352c commit 90a2c5d

15 files changed

+867
-389
lines changed

CHANGELOG.md

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

1616
### Changed
1717

18+
- Filters appear in the search bar as ?f=is,page,/docs,/blog&f=... instead of ?filters=((is,page,(/docs,/blog)),...) for Plausible links sent on various platforms to work reliably.
1819
- Details modal search inputs are now case-insensitive.
1920
- Improved report performance in cases where site has a lot of unique pathnames
2021

assets/js/dashboard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createAppRouter } from './dashboard/router'
99
import ErrorBoundary from './dashboard/error/error-boundary'
1010
import * as api from './dashboard/api'
1111
import * as timer from './dashboard/util/realtime-update-timer'
12-
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query'
12+
import { redirectForLegacyParams } from './dashboard/util/url-search-params'
1313
import SiteContextProvider, {
1414
parseSiteFromDataset
1515
} from './dashboard/site-context'
@@ -38,7 +38,7 @@ if (container && container.dataset) {
3838
}
3939

4040
try {
41-
filtersBackwardsCompatibilityRedirect(window.location, window.history)
41+
redirectForLegacyParams(window.location, window.history)
4242
} catch (e) {
4343
console.error('Error redirecting in a backwards compatible way', e)
4444
}

assets/js/dashboard/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
/** @format */
22

3-
import React, { useState } from 'react'
4-
5-
import { useIsRealtimeDashboard } from './util/filters'
3+
import React, { useMemo, useState } from 'react'
64
import VisitorGraph from './stats/graph/visitor-graph'
75
import Sources from './stats/sources'
86
import Pages from './stats/pages'
@@ -11,6 +9,8 @@ import Devices from './stats/devices'
119
import { TopBar } from './nav-menu/top-bar'
1210
import Behaviours from './stats/behaviours'
1311
import { FiltersBar } from './nav-menu/filters-bar'
12+
import { useQueryContext } from './query-context'
13+
import { isRealTimeDashboard } from './util/filters'
1414

1515
function DashboardStats({
1616
importedDataInView,
@@ -48,6 +48,13 @@ function DashboardStats({
4848
)
4949
}
5050

51+
function useIsRealtimeDashboard() {
52+
const {
53+
query: { period }
54+
} = useQueryContext()
55+
return useMemo(() => isRealTimeDashboard({ period }), [period])
56+
}
57+
5158
function Dashboard() {
5259
const isRealTimeDashboard = useIsRealtimeDashboard()
5360
const [importedDataInView, setImportedDataInView] = useState(false)

assets/js/dashboard/nav-menu/filters-bar.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event'
66
import { TestContextProviders } from '../../../test-utils/app-context-providers'
77
import { FiltersBar, handleVisibility } from './filters-bar'
88
import { getRouterBasepath } from '../router'
9-
import { stringifySearch } from '../util/url'
9+
import { stringifySearch } from '../util/url-search-params'
1010

1111
const domain = 'dummy.site'
1212

assets/js/dashboard/navigation/use-app-navigate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
NavigateOptions,
1010
LinkProps
1111
} from 'react-router-dom'
12-
import { parseSearch, stringifySearch } from '../util/url'
12+
import { parseSearch, stringifySearch } from '../util/url-search-params'
1313

1414
export type AppNavigationTarget = {
1515
/**

assets/js/dashboard/query-context.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useLocation } from 'react-router'
44
import { useMountedEffect } from './custom-hooks'
55
import * as api from './api'
66
import { useSiteContext } from './site-context'
7-
import { parseSearch } from './util/url'
7+
import { parseSearch } from './util/url-search-params'
88
import dayjs from 'dayjs'
99
import { nowForSite, yesterday } from './util/date'
1010
import {

assets/js/dashboard/query-dates.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'
55
import userEvent from '@testing-library/user-event'
66
import DatePicker from './datepicker'
77
import { TestContextProviders } from '../../test-utils/app-context-providers'
8-
import { stringifySearch } from './util/url'
8+
import { stringifySearch } from './util/url-search-params'
99
import { useNavigate } from 'react-router-dom'
1010
import { getRouterBasepath } from './router'
1111

assets/js/dashboard/query.ts

Lines changed: 2 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/** @format */
22

3-
import { parseSearch, stringifySearch } from './util/url'
43
import {
54
nowForSite,
65
formatISO,
@@ -10,12 +9,7 @@ import {
109
parseUTCDate,
1110
isAfter
1211
} from './util/date'
13-
import {
14-
FILTER_OPERATIONS,
15-
getFiltersByKeyPrefix,
16-
parseLegacyFilter,
17-
parseLegacyPropsFilter
18-
} from './util/filters'
12+
import { FILTER_OPERATIONS, getFiltersByKeyPrefix } from './util/filters'
1913
import { PlausibleSite } from './site-context'
2014
import { ComparisonMode, QueryPeriod } from './query-time-periods'
2115
import { AppNavigationTarget } from './navigation/use-app-navigate'
@@ -37,7 +31,7 @@ export type Filter = [FilterOperator, FilterKey, FilterClause[]]
3731
* for filters `[["is", "city", [2761369]], ["is", "country", ["AT"]]]`,
3832
* labels would be `{"2761369": "Vienna", "AT": "Austria"}`
3933
* */
40-
export type FilterClauseLabels = Record<string, unknown>
34+
export type FilterClauseLabels = Record<string, string>
4135

4236
export const queryDefaultValue = {
4337
period: '30d' as QueryPeriod,
@@ -67,29 +61,6 @@ export function addFilter(
6761
return { ...query, filters: [...query.filters, filter] }
6862
}
6963

70-
const LEGACY_URL_PARAMETERS = {
71-
goal: null,
72-
source: null,
73-
utm_medium: null,
74-
utm_source: null,
75-
utm_campaign: null,
76-
utm_content: null,
77-
utm_term: null,
78-
referrer: null,
79-
screen: null,
80-
browser: null,
81-
browser_version: null,
82-
os: null,
83-
os_version: null,
84-
country: 'country_labels',
85-
region: 'region_labels',
86-
city: 'city_labels',
87-
page: null,
88-
hostname: null,
89-
entry_page: null,
90-
exit_page: null
91-
}
92-
9364
export function postProcessFilters(filters: Array<Filter>): Array<Filter> {
9465
return filters.map(([operation, dimension, clauses]) => {
9566
// Rename old name of the operation
@@ -100,60 +71,6 @@ export function postProcessFilters(filters: Array<Filter>): Array<Filter> {
10071
})
10172
}
10273

103-
// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
104-
// updates the filters and updates location
105-
export function filtersBackwardsCompatibilityRedirect(
106-
windowLocation: Location,
107-
windowHistory: History
108-
) {
109-
const searchRecord = parseSearch(windowLocation.search)
110-
const getValue = (k: string) => searchRecord[k]
111-
112-
// New filters are used - no need to do anything
113-
if (getValue('filters')) {
114-
return
115-
}
116-
117-
const changedSearchRecordEntries = []
118-
const filters: DashboardQuery['filters'] = []
119-
let labels: DashboardQuery['labels'] = {}
120-
121-
for (const [key, value] of Object.entries(searchRecord)) {
122-
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
123-
const filter = parseLegacyFilter(key, value) as Filter
124-
filters.push(filter)
125-
const labelsKey: string | null | undefined =
126-
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
127-
if (labelsKey && getValue(labelsKey)) {
128-
const clauses = filter[2]
129-
const labelsValues = (getValue(labelsKey) as string)
130-
.split('|')
131-
.filter((label) => !!label)
132-
const newLabels = Object.fromEntries(
133-
clauses.map((clause, index) => [clause, labelsValues[index]])
134-
)
135-
136-
labels = Object.assign(labels, newLabels)
137-
}
138-
} else {
139-
changedSearchRecordEntries.push([key, value])
140-
}
141-
}
142-
143-
if (getValue('props')) {
144-
filters.push(...(parseLegacyPropsFilter(getValue('props')) as Filter[]))
145-
}
146-
147-
if (filters.length > 0) {
148-
changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
149-
windowHistory.pushState(
150-
{},
151-
'',
152-
`${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`
153-
)
154-
}
155-
}
156-
15774
// Returns a boolean indicating whether the given query includes a
15875
// non-empty goal filterset containing a single, or multiple revenue
15976
// goals with the same currency. Used to decide whether to render

assets/js/dashboard/util/filters.js

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
/** @format */
22

3-
import React, { useMemo } from 'react'
3+
import React from 'react'
44
import * as api from '../api'
5-
import { useQueryContext } from '../query-context'
65

76
export const FILTER_MODAL_TO_FILTER_GROUP = {
87
page: ['page', 'entry_page', 'exit_page'],
@@ -40,12 +39,6 @@ export const FILTER_OPERATIONS_DISPLAY_NAMES = {
4039
[FILTER_OPERATIONS.contains_not]: 'does not contain'
4140
}
4241

43-
const OPERATION_PREFIX = {
44-
[FILTER_OPERATIONS.isNot]: '!',
45-
[FILTER_OPERATIONS.contains]: '~',
46-
[FILTER_OPERATIONS.is]: ''
47-
}
48-
4942
export function supportsIsNot(filterName) {
5043
return !['goal', 'prop_key'].includes(filterName)
5144
}
@@ -62,17 +55,6 @@ export function isFreeChoiceFilterOperation(operation) {
6255
)
6356
}
6457

65-
// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
66-
// escaping pipe characters in filters does not currently work in Safari
67-
let NON_ESCAPED_PIPE_REGEX
68-
try {
69-
NON_ESCAPED_PIPE_REGEX = new RegExp('(?<!\\\\)\\|', 'g')
70-
} catch (_e) {
71-
NON_ESCAPED_PIPE_REGEX = '|'
72-
}
73-
74-
const ESCAPED_PIPE = '\\|'
75-
7658
export function getLabel(labels, filterKey, value) {
7759
if (['country', 'region', 'city'].includes(filterKey)) {
7860
return labels[value]
@@ -118,27 +100,10 @@ export function hasGoalFilter(query) {
118100
return getFiltersByKeyPrefix(query, 'goal').length > 0
119101
}
120102

121-
export function useHasGoalFilter() {
122-
const {
123-
query: { filters }
124-
} = useQueryContext()
125-
return useMemo(
126-
() => getFiltersByKeyPrefix({ filters }, 'goal').length > 0,
127-
[filters]
128-
)
129-
}
130-
131103
export function isRealTimeDashboard(query) {
132104
return query?.period === 'realtime'
133105
}
134106

135-
export function useIsRealtimeDashboard() {
136-
const {
137-
query: { period }
138-
} = useQueryContext()
139-
return useMemo(() => isRealTimeDashboard({ period }), [period])
140-
}
141-
142107
export function plainFilterText(query, [operation, filterKey, clauses]) {
143108
const formattedFilter = formattedFilters[filterKey]
144109

@@ -295,26 +260,3 @@ export const formattedFilters = {
295260
entry_page: 'Entry Page',
296261
exit_page: 'Exit Page'
297262
}
298-
299-
export function parseLegacyFilter(filterKey, rawValue) {
300-
const operation =
301-
Object.keys(OPERATION_PREFIX).find(
302-
(operation) => OPERATION_PREFIX[operation] === rawValue[0]
303-
) || FILTER_OPERATIONS.is
304-
305-
const value =
306-
operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
307-
308-
const clauses = value
309-
.split(NON_ESCAPED_PIPE_REGEX)
310-
.filter((clause) => !!clause)
311-
.map((val) => val.replaceAll(ESCAPED_PIPE, '|'))
312-
313-
return [operation, filterKey, clauses]
314-
}
315-
316-
export function parseLegacyPropsFilter(rawValue) {
317-
return Object.entries(JSON.parse(rawValue)).map(([key, propVal]) => {
318-
return parseLegacyFilter(`${EVENT_PROPS_PREFIX}${key}`, propVal)
319-
})
320-
}

0 commit comments

Comments
 (0)