Skip to content

Commit

Permalink
Flatten ?filters=(...)&labels=(...) to ?f=...&f=...&l=...&l=... (#4810)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
apata authored Jan 16, 2025
1 parent 558352c commit 90a2c5d
Show file tree
Hide file tree
Showing 15 changed files with 867 additions and 389 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.

### Changed

- 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.
- Details modal search inputs are now case-insensitive.
- Improved report performance in cases where site has a lot of unique pathnames

Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createAppRouter } from './dashboard/router'
import ErrorBoundary from './dashboard/error/error-boundary'
import * as api from './dashboard/api'
import * as timer from './dashboard/util/realtime-update-timer'
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query'
import { redirectForLegacyParams } from './dashboard/util/url-search-params'
import SiteContextProvider, {
parseSiteFromDataset
} from './dashboard/site-context'
Expand Down Expand Up @@ -38,7 +38,7 @@ if (container && container.dataset) {
}

try {
filtersBackwardsCompatibilityRedirect(window.location, window.history)
redirectForLegacyParams(window.location, window.history)
} catch (e) {
console.error('Error redirecting in a backwards compatible way', e)
}
Expand Down
13 changes: 10 additions & 3 deletions assets/js/dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/** @format */

import React, { useState } from 'react'

import { useIsRealtimeDashboard } from './util/filters'
import React, { useMemo, useState } from 'react'
import VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources'
import Pages from './stats/pages'
Expand All @@ -11,6 +9,8 @@ import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours'
import { FiltersBar } from './nav-menu/filters-bar'
import { useQueryContext } from './query-context'
import { isRealTimeDashboard } from './util/filters'

function DashboardStats({
importedDataInView,
Expand Down Expand Up @@ -48,6 +48,13 @@ function DashboardStats({
)
}

function useIsRealtimeDashboard() {
const {
query: { period }
} = useQueryContext()
return useMemo(() => isRealTimeDashboard({ period }), [period])
}

function Dashboard() {
const isRealTimeDashboard = useIsRealtimeDashboard()
const [importedDataInView, setImportedDataInView] = useState(false)
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/nav-menu/filters-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event'
import { TestContextProviders } from '../../../test-utils/app-context-providers'
import { FiltersBar, handleVisibility } from './filters-bar'
import { getRouterBasepath } from '../router'
import { stringifySearch } from '../util/url'
import { stringifySearch } from '../util/url-search-params'

const domain = 'dummy.site'

Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/navigation/use-app-navigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
NavigateOptions,
LinkProps
} from 'react-router-dom'
import { parseSearch, stringifySearch } from '../util/url'
import { parseSearch, stringifySearch } from '../util/url-search-params'

export type AppNavigationTarget = {
/**
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/query-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useLocation } from 'react-router'
import { useMountedEffect } from './custom-hooks'
import * as api from './api'
import { useSiteContext } from './site-context'
import { parseSearch } from './util/url'
import { parseSearch } from './util/url-search-params'
import dayjs from 'dayjs'
import { nowForSite, yesterday } from './util/date'
import {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/query-dates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DatePicker from './datepicker'
import { TestContextProviders } from '../../test-utils/app-context-providers'
import { stringifySearch } from './util/url'
import { stringifySearch } from './util/url-search-params'
import { useNavigate } from 'react-router-dom'
import { getRouterBasepath } from './router'

Expand Down
87 changes: 2 additions & 85 deletions assets/js/dashboard/query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/** @format */

import { parseSearch, stringifySearch } from './util/url'
import {
nowForSite,
formatISO,
Expand All @@ -10,12 +9,7 @@ import {
parseUTCDate,
isAfter
} from './util/date'
import {
FILTER_OPERATIONS,
getFiltersByKeyPrefix,
parseLegacyFilter,
parseLegacyPropsFilter
} from './util/filters'
import { FILTER_OPERATIONS, getFiltersByKeyPrefix } from './util/filters'
import { PlausibleSite } from './site-context'
import { ComparisonMode, QueryPeriod } from './query-time-periods'
import { AppNavigationTarget } from './navigation/use-app-navigate'
Expand All @@ -37,7 +31,7 @@ export type Filter = [FilterOperator, FilterKey, FilterClause[]]
* for filters `[["is", "city", [2761369]], ["is", "country", ["AT"]]]`,
* labels would be `{"2761369": "Vienna", "AT": "Austria"}`
* */
export type FilterClauseLabels = Record<string, unknown>
export type FilterClauseLabels = Record<string, string>

export const queryDefaultValue = {
period: '30d' as QueryPeriod,
Expand Down Expand Up @@ -67,29 +61,6 @@ export function addFilter(
return { ...query, filters: [...query.filters, filter] }
}

const LEGACY_URL_PARAMETERS = {
goal: null,
source: null,
utm_medium: null,
utm_source: null,
utm_campaign: null,
utm_content: null,
utm_term: null,
referrer: null,
screen: null,
browser: null,
browser_version: null,
os: null,
os_version: null,
country: 'country_labels',
region: 'region_labels',
city: 'city_labels',
page: null,
hostname: null,
entry_page: null,
exit_page: null
}

export function postProcessFilters(filters: Array<Filter>): Array<Filter> {
return filters.map(([operation, dimension, clauses]) => {
// Rename old name of the operation
Expand All @@ -100,60 +71,6 @@ export function postProcessFilters(filters: Array<Filter>): Array<Filter> {
})
}

// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
// updates the filters and updates location
export function filtersBackwardsCompatibilityRedirect(
windowLocation: Location,
windowHistory: History
) {
const searchRecord = parseSearch(windowLocation.search)
const getValue = (k: string) => searchRecord[k]

// New filters are used - no need to do anything
if (getValue('filters')) {
return
}

const changedSearchRecordEntries = []
const filters: DashboardQuery['filters'] = []
let labels: DashboardQuery['labels'] = {}

for (const [key, value] of Object.entries(searchRecord)) {
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
const filter = parseLegacyFilter(key, value) as Filter
filters.push(filter)
const labelsKey: string | null | undefined =
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
if (labelsKey && getValue(labelsKey)) {
const clauses = filter[2]
const labelsValues = (getValue(labelsKey) as string)
.split('|')
.filter((label) => !!label)
const newLabels = Object.fromEntries(
clauses.map((clause, index) => [clause, labelsValues[index]])
)

labels = Object.assign(labels, newLabels)
}
} else {
changedSearchRecordEntries.push([key, value])
}
}

if (getValue('props')) {
filters.push(...(parseLegacyPropsFilter(getValue('props')) as Filter[]))
}

if (filters.length > 0) {
changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
windowHistory.pushState(
{},
'',
`${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`
)
}
}

// Returns a boolean indicating whether the given query includes a
// non-empty goal filterset containing a single, or multiple revenue
// goals with the same currency. Used to decide whether to render
Expand Down
60 changes: 1 addition & 59 deletions assets/js/dashboard/util/filters.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/** @format */

import React, { useMemo } from 'react'
import React from 'react'
import * as api from '../api'
import { useQueryContext } from '../query-context'

export const FILTER_MODAL_TO_FILTER_GROUP = {
page: ['page', 'entry_page', 'exit_page'],
Expand Down Expand Up @@ -40,12 +39,6 @@ export const FILTER_OPERATIONS_DISPLAY_NAMES = {
[FILTER_OPERATIONS.contains_not]: 'does not contain'
}

const OPERATION_PREFIX = {
[FILTER_OPERATIONS.isNot]: '!',
[FILTER_OPERATIONS.contains]: '~',
[FILTER_OPERATIONS.is]: ''
}

export function supportsIsNot(filterName) {
return !['goal', 'prop_key'].includes(filterName)
}
Expand All @@ -62,17 +55,6 @@ export function isFreeChoiceFilterOperation(operation) {
)
}

// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
// escaping pipe characters in filters does not currently work in Safari
let NON_ESCAPED_PIPE_REGEX
try {
NON_ESCAPED_PIPE_REGEX = new RegExp('(?<!\\\\)\\|', 'g')
} catch (_e) {
NON_ESCAPED_PIPE_REGEX = '|'
}

const ESCAPED_PIPE = '\\|'

export function getLabel(labels, filterKey, value) {
if (['country', 'region', 'city'].includes(filterKey)) {
return labels[value]
Expand Down Expand Up @@ -118,27 +100,10 @@ export function hasGoalFilter(query) {
return getFiltersByKeyPrefix(query, 'goal').length > 0
}

export function useHasGoalFilter() {
const {
query: { filters }
} = useQueryContext()
return useMemo(
() => getFiltersByKeyPrefix({ filters }, 'goal').length > 0,
[filters]
)
}

export function isRealTimeDashboard(query) {
return query?.period === 'realtime'
}

export function useIsRealtimeDashboard() {
const {
query: { period }
} = useQueryContext()
return useMemo(() => isRealTimeDashboard({ period }), [period])
}

export function plainFilterText(query, [operation, filterKey, clauses]) {
const formattedFilter = formattedFilters[filterKey]

Expand Down Expand Up @@ -295,26 +260,3 @@ export const formattedFilters = {
entry_page: 'Entry Page',
exit_page: 'Exit Page'
}

export function parseLegacyFilter(filterKey, rawValue) {
const operation =
Object.keys(OPERATION_PREFIX).find(
(operation) => OPERATION_PREFIX[operation] === rawValue[0]
) || FILTER_OPERATIONS.is

const value =
operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)

const clauses = value
.split(NON_ESCAPED_PIPE_REGEX)
.filter((clause) => !!clause)
.map((val) => val.replaceAll(ESCAPED_PIPE, '|'))

return [operation, filterKey, clauses]
}

export function parseLegacyPropsFilter(rawValue) {
return Object.entries(JSON.parse(rawValue)).map(([key, propVal]) => {
return parseLegacyFilter(`${EVENT_PROPS_PREFIX}${key}`, propVal)
})
}
Loading

0 comments on commit 90a2c5d

Please sign in to comment.