diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e7673b20ff..0019677231d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/js/dashboard.tsx b/assets/js/dashboard.tsx index ad44acc74dfc..227ba57c924b 100644 --- a/assets/js/dashboard.tsx +++ b/assets/js/dashboard.tsx @@ -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' @@ -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) } diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 35c3148610c0..afa3fe356f11 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -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' @@ -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, @@ -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) diff --git a/assets/js/dashboard/nav-menu/filters-bar.test.tsx b/assets/js/dashboard/nav-menu/filters-bar.test.tsx index 9cca865b04df..97d83577c3e2 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.test.tsx @@ -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' diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx index b30d888eca44..8fdc736c5061 100644 --- a/assets/js/dashboard/navigation/use-app-navigate.tsx +++ b/assets/js/dashboard/navigation/use-app-navigate.tsx @@ -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 = { /** diff --git a/assets/js/dashboard/query-context.tsx b/assets/js/dashboard/query-context.tsx index a523125a42c7..8acf552e12ee 100644 --- a/assets/js/dashboard/query-context.tsx +++ b/assets/js/dashboard/query-context.tsx @@ -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 { diff --git a/assets/js/dashboard/query-dates.test.tsx b/assets/js/dashboard/query-dates.test.tsx index 69ff78fdf742..234f96693b2d 100644 --- a/assets/js/dashboard/query-dates.test.tsx +++ b/assets/js/dashboard/query-dates.test.tsx @@ -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' diff --git a/assets/js/dashboard/query.ts b/assets/js/dashboard/query.ts index c9d345220147..cb34a236648f 100644 --- a/assets/js/dashboard/query.ts +++ b/assets/js/dashboard/query.ts @@ -1,6 +1,5 @@ /** @format */ -import { parseSearch, stringifySearch } from './util/url' import { nowForSite, formatISO, @@ -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' @@ -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 +export type FilterClauseLabels = Record export const queryDefaultValue = { period: '30d' as QueryPeriod, @@ -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): Array { return filters.map(([operation, dimension, clauses]) => { // Rename old name of the operation @@ -100,60 +71,6 @@ export function postProcessFilters(filters: Array): Array { }) } -// 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 diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js index 12e9a60c604e..082573c2aa94 100644 --- a/assets/js/dashboard/util/filters.js +++ b/assets/js/dashboard/util/filters.js @@ -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'], @@ -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) } @@ -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('(? 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] @@ -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) - }) -} diff --git a/assets/js/dashboard/util/url-search-params-v1.ts b/assets/js/dashboard/util/url-search-params-v1.ts new file mode 100644 index 000000000000..a4398c37da0a --- /dev/null +++ b/assets/js/dashboard/util/url-search-params-v1.ts @@ -0,0 +1,120 @@ +/** @format */ + +import { DashboardQuery, Filter } from '../query' +import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS } from './filters' + +// 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: string | RegExp +try { + NON_ESCAPED_PIPE_REGEX = new RegExp('(?): boolean { + return Object.keys(searchRecord).some( + (k) => k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k) + ) +} + +function parseSearchRecord( + searchRecord: Record +): Record { + const searchRecordEntries = Object.entries(searchRecord) + const updatedSearchRecordEntries = [] + const filters: Filter[] = [] + let labels: DashboardQuery['labels'] = {} + + for (const [key, value] of searchRecordEntries) { + if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) { + if (typeof value !== 'string') { + continue + } + 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 && searchRecord[labelsKey]) { + const clauses = filter[2] + const labelsValues = (searchRecord[labelsKey] as string) + .split('|') + .filter((label) => !!label) + const newLabels = Object.fromEntries( + clauses.map((clause, index) => [clause, labelsValues[index]]) + ) + + labels = Object.assign(labels, newLabels) + } + } else { + updatedSearchRecordEntries.push([key, value]) + } + } + + if (typeof searchRecord['props'] === 'string') { + filters.push(...(parseLegacyPropsFilter(searchRecord['props']) as Filter[])) + } + updatedSearchRecordEntries.push(['filters', filters], ['labels', labels]) + return Object.fromEntries(updatedSearchRecordEntries) +} + +function parseLegacyFilter(filterKey: string, rawValue: string): null | Filter { + 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) + // @ts-expect-error API supposedly not present in compilation target, but works anyway + .map((val) => val.replaceAll(ESCAPED_PIPE, '|')) + + return [operation, filterKey, clauses] +} + +function parseLegacyPropsFilter(rawValue: string) { + return Object.entries(JSON.parse(rawValue)).flatMap(([key, propVal]) => + typeof propVal === 'string' + ? [parseLegacyFilter(`${EVENT_PROPS_PREFIX}${key}`, propVal)] + : [] + ) +} + +export const v1 = { + isV1, + parseSearchRecord +} diff --git a/assets/js/dashboard/util/url-search-params-v2.test.ts b/assets/js/dashboard/util/url-search-params-v2.test.ts new file mode 100644 index 000000000000..72ee4182e4c1 --- /dev/null +++ b/assets/js/dashboard/util/url-search-params-v2.test.ts @@ -0,0 +1,206 @@ +/** @format */ + +import JsonURL from '@jsonurl/jsonurl' +import { v2 } from './url-search-params-v2' + +const { + stringifySearchEntry, + stringifySearch, + parseSearch, + parseSearchFragment +} = v2 + +describe('using json URL parsing with URLSearchParams intermediate', () => { + beforeEach(() => { + // Silence logs in tests + jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => { + const searchString = `?param=${encodeURIComponent(s)}` + expect(() => + JsonURL.parse(new URLSearchParams(searchString).get('param')!) + ).toThrow() + }) +}) + +describe(`${stringifySearchEntry.name}`, () => { + it.each<[[string, unknown], [string, string | undefined]]>([ + [ + ['any-key', {}], + ['any-key', undefined] + ], + [ + ['any-key', []], + ['any-key', undefined] + ], + [ + ['any-key', null], + ['any-key', undefined] + ], + [ + ['period', 'realtime'], + ['period', 'realtime'] + ], + [ + ['page', 10], + ['page', '10'] + ], + [ + ['labels', { US: 'United States', 3448439: 'São Paulo' }], + ['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)'] + ], + [ + ['filters', [['is', 'props:foo:bar', ['one', 'two']]]], + ['filters', "((is,'props:foo:bar',(one,two)))"] + ] + ])('when input is %p, returns %p', (input, expected) => { + const result = stringifySearchEntry(input) + expect(result).toEqual(expected) + }) +}) + +describe(`${parseSearchFragment.name}`, () => { + it.each([ + ['', null], + ['("foo":)', null], + ['(invalid', null], + ['null', null], + + ['123', 123], + ['string', 'string'], + ['item=#', 'item=#'], + ['item%3D%23', 'item=#'], + + ['(any:(number:1))', { any: { number: 1 } }], + ['(any:(number:1.001))', { any: { number: 1.001 } }], + ["(any:(string:'1.001'))", { any: { string: '1.001' } }], + + // Non-JSON strings that should return as string + ['undefined', 'undefined'], + ['not_json', 'not_json'], + ['plainstring', 'plainstring'], + ['a|b', 'a|b'], + ['foo bar#', 'foo bar#'] + ])( + 'when searchStringFragment is %p, returns %p', + (searchStringFragment, expected) => { + const result = parseSearchFragment(searchStringFragment) + expect(result).toEqual(expected) + } + ) +}) + +describe(`${parseSearch.name}`, () => { + it.each([ + ['', {}], + ['?', {}], + [ + '?arr=(1,2)', + { + arr: [1, 2] + } + ], + ['?key1=value1&key2=', { key1: 'value1', key2: null }], + ['?key1=value1&key2=value2', { key1: 'value1', key2: 'value2' }], + [ + '?key1=(foo:bar)&filters=((is,screen,(Mobile,Desktop)))', + { + key1: { foo: 'bar' }, + filters: [['is', 'screen', ['Mobile', 'Desktop']]] + } + ], + [ + '?filters=((is,country,(US)))&labels=(US:United%2BStates)', + { + filters: [['is', 'country', ['US']]], + labels: { + US: 'United States' + } + } + ] + ])('when searchString is %p, returns %p', (searchString, expected) => { + const result = parseSearch(searchString) + expect(result).toEqual(expected) + }) +}) + +describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => { + it.each([ + ["?filters=((is,'props:browser_language',(en-US)))"], + [ + '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(/open-source-website-analytics)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10' + ], + [ + "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)" + ], + [ + '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' + ] + ])( + `input %p is returned for ${parseSearch.name}(${parseSearch.name}(input))`, + (searchString) => { + const searchRecord = parseSearch(searchString) + const reStringifiedSearch = stringifySearch(searchRecord) + expect(reStringifiedSearch).toEqual(searchString) + } + ) + + it.each([ + // Corresponding test cases for objects parsed from realistic URLs + + [ + { + filters: [['is', 'props:browser_language', ['en-US']]] + }, + "?filters=((is,'props:browser_language',(en-US)))" + ], + [ + { + filters: [ + ['contains', 'utm_term', ['_']], + ['is', 'screen', ['Desktop', 'Tablet']], + ['is', 'page', ['/open-source/analytics/encoded-hash%23']] + ], + period: 'custom', + keybindHint: 'A', + comparison: 'previous_period', + match_day_of_week: false, + from: '2024-08-08', + to: '2024-08-10' + }, + '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(%252Fopen-source%252Fanalytics%252Fencoded-hash%252523)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10' + ], + [ + { + filters: [ + ['is', 'props:browser_language', ['en-US']], + ['is', 'country', ['US']], + ['is', 'os', ['iOS']], + ['is', 'os_version', ['17.3']], + ['is', 'page', ['/:dashboard/settings/general']] + ], + labels: { US: 'United States' } + }, + "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)" + ], + [ + { + filters: [ + ['is', 'utm_source', ['hackernewsletter']], + ['is', 'utm_campaign', ['profile']] + ], + period: 'day', + keybindHint: 'D' + }, + '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' + ] + ])( + `for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`, + (searchRecord, expected) => { + const searchString = stringifySearch(searchRecord) + const parsedSearchRecord = parseSearch(searchString) + expect(parsedSearchRecord).toEqual(searchRecord) + expect(searchString).toEqual(expected) + } + ) +}) diff --git a/assets/js/dashboard/util/url-search-params-v2.ts b/assets/js/dashboard/util/url-search-params-v2.ts new file mode 100644 index 000000000000..98377f8971a6 --- /dev/null +++ b/assets/js/dashboard/util/url-search-params-v2.ts @@ -0,0 +1,84 @@ +/* @format */ +import JsonURL from '@jsonurl/jsonurl' +import { + encodeURIComponentPermissive, + isSearchEntryDefined +} from './url-search-params' + +const permittedCharactersInURLParamKeyValue = ',:/' + +function isV2(urlSearchParams: URLSearchParams): boolean { + return !!urlSearchParams.get('filters') +} + +function encodeSearchParamEntry([k, v]: [string, string]): string { + return [k, v] + .map((s) => + encodeURIComponentPermissive(s, permittedCharactersInURLParamKeyValue) + ) + .join('=') +} + +function stringifySearch(searchRecord: Record): '' | string { + const definedSearchEntries = Object.entries(searchRecord || {}) + .map(stringifySearchEntry) + .filter(isSearchEntryDefined) + + const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry) + + return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : '' +} + +function stringifySearchEntry([key, value]: [string, unknown]): [ + string, + undefined | string +] { + const isEmptyObjectOrArray = + typeof value === 'object' && + value !== null && + Object.entries(value).length === 0 + if (value === undefined || value === null || isEmptyObjectOrArray) { + return [key, undefined] + } + + return [key, JsonURL.stringify(value)] +} + +function parseSearchFragment(searchStringFragment: string): null | unknown { + if (searchStringFragment === '') { + return null + } + // tricky: the search string fragment is already decoded due to URLSearchParams intermediate (see tests), + // and these symbols are unparseable + const fragmentWithReEncodedSymbols = searchStringFragment + /* @ts-expect-error API supposedly not present in compilation target */ + .replaceAll('=', encodeURIComponent('=')) + .replaceAll('#', encodeURIComponent('#')) + .replaceAll('|', encodeURIComponent('|')) + .replaceAll(' ', encodeURIComponent(' ')) + + try { + return JsonURL.parse(fragmentWithReEncodedSymbols) + } catch (error) { + console.error( + `Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`, + error + ) + return null + } +} + +function parseSearch(searchString: string): Record { + const urlSearchParams = new URLSearchParams(searchString) + const searchRecord: Record = {} + urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v))) + return searchRecord +} + +export const v2 = { + isV2, + parseSearch, + parseSearchFragment, + stringifySearch, + stringifySearchEntry +} diff --git a/assets/js/dashboard/util/url-search-params.test.ts b/assets/js/dashboard/util/url-search-params.test.ts index fb73c018088e..95fd65d9fac5 100644 --- a/assets/js/dashboard/util/url-search-params.test.ts +++ b/assets/js/dashboard/util/url-search-params.test.ts @@ -1,29 +1,19 @@ /** @format */ -import JsonURL from '@jsonurl/jsonurl' +import { Filter } from '../query' import { - encodeSearchParamEntry, encodeURIComponentPermissive, isSearchEntryDefined, + getRedirectTarget, + parseFilter, + parseLabelsEntry, parseSearch, - parseSearchFragment, - stringifySearch, - stringifySearchEntry -} from './url' - -beforeEach(() => { - // Silence logs in tests - jest.spyOn(console, 'error').mockImplementation(jest.fn()) -}) - -describe('using json URL parsing with URLSearchParams intermediate', () => { - it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => { - const searchString = `?param=${encodeURIComponent(s)}` - expect(() => - JsonURL.parse(new URLSearchParams(searchString).get('param')!) - ).toThrow() - }) -}) + parseSimpleSearchEntry, + serializeFilter, + serializeLabelsEntry, + serializeSimpleSearchEntry, + stringifySearch +} from './url-search-params' describe(`${encodeURIComponentPermissive.name}`, () => { it.each<[string, string]>([ @@ -37,7 +27,7 @@ describe(`${encodeURIComponentPermissive.name}`, () => { ])( 'when input is %p, returns %s and decodes back to input', (input, expected) => { - const result = encodeURIComponentPermissive(input) + const result = encodeURIComponentPermissive(input, ',:/') expect(result).toBe(expected) expect(decodeURIComponent(result)).toBe(input) } @@ -56,155 +46,139 @@ describe(`${isSearchEntryDefined.name}`, () => { }) }) -describe(`${stringifySearchEntry.name}`, () => { - it.each<[[string, unknown], [string, string | undefined]]>([ +describe(`${serializeLabelsEntry.name} and ${parseLabelsEntry.name}(...) are opposite of each other`, () => { + test.each<[[string, string], string]>([ + [['US', 'United States'], 'US,United%20States'], + [['FR-IDF', 'Île-de-France'], 'FR-IDF,%C3%8Ele-de-France'], + [['1254661', 'Thāne'], '1254661,Th%C4%81ne'] + ])( + 'entry %p serializes to %p, parses back to original', + (entry, expected) => { + const serialized = serializeLabelsEntry(entry) + expect(serialized).toEqual(expected) + expect(parseLabelsEntry(serialized)).toEqual(entry) + } + ) +}) + +describe(`${serializeFilter.name} and ${parseFilter.name}(...) are opposite of each other`, () => { + test.each<[Filter, string]>([ [ - ['any-key', {}], - ['any-key', undefined] + ['contains', 'entry_page', ['/forecast/:city', ',"\'']], + "contains,entry_page,/forecast/:city,%2C%22'" ], [ - ['any-key', []], - ['any-key', undefined] - ], + ['is', 'props:complex/prop-with-comma-etc,$#%', ['(none)']], + 'is,props:complex/prop-with-comma-etc%2C%24%23%25,(none)' + ] + ])( + 'filter %p serializes to %p, parses back to original', + (filter, expected) => { + const serialized = serializeFilter(filter) + expect(serialized).toEqual(expected) + expect(parseFilter(serialized)).toEqual(filter) + } + ) +}) + +describe(`${serializeSimpleSearchEntry.name} and ${parseSimpleSearchEntry.name}`, () => { + test.each< [ - ['any-key', null], - ['any-key', undefined] + [string, unknown], + [string, string | boolean | undefined], + [string, string | boolean] | null + ] + >([ + [['undefined-param', undefined], ['undefined-param', undefined], null], + [['null-param', null], ['null-param', undefined], null], + [['array-param', ['any-value']], ['array-param', undefined], null], + [['obj-param', { 'any-key': 'any-value' }], ['obj-param', undefined], null], + [ + ['date-obj', new Date('2024-01-01T10:00:00.000Z')], + ['date-obj', undefined], + null ], [ - ['period', 'realtime'], - ['period', 'realtime'] + ['page-nr', 5], + ['page-nr', '5'], + ['page-nr', '5'] ], [ - ['page', 10], - ['page', '10'] + ['string-param-resembling-boolean', 'true'], + ['string-param-resembling-boolean', 'true'], + ['string-param-resembling-boolean', true] ], [ - ['labels', { US: 'United States', 3448439: 'São Paulo' }], - ['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)'] + ['match-day-of-week', false], + ['match-day-of-week', 'false'], + ['match-day-of-week', false] ], [ - ['filters', [['is', 'props:foo:bar', ['one', 'two']]]], - ['filters', "((is,'props:foo:bar',(one,two)))"] - ] - ])('when input is %p, returns %p', (input, expected) => { - const result = stringifySearchEntry(input) - expect(result).toEqual(expected) - }) -}) - -describe(`${encodeSearchParamEntry.name}`, () => { - it.each<[[string, string], string]>([ + ['with-imported-data', true], + ['with-imported-data', 'true'], + ['with-imported-data', true] + ], [ - ['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)'], - 'labels=(3448439:S%25C3%25A3o%2BPaulo,US:United%2BStates)' + ['date-string', '2024-12-10'], + ['date-string', '2024-12-10'], + ['date-string', '2024-12-10'] ] - ])('when input is %p, returns %s', (input, expected) => { - const result = encodeSearchParamEntry(input) - expect(result).toBe(expected) - }) -}) - -describe(`${parseSearchFragment.name}`, () => { - it.each([ - ['', null], - ['("foo":)', null], - ['(invalid', null], - ['null', null], - - ['123', 123], - ['string', 'string'], - ['item=#', 'item=#'], - ['item%3D%23', 'item=#'], - - ['(any:(number:1))', { any: { number: 1 } }], - ['(any:(number:1.001))', { any: { number: 1.001 } }], - ["(any:(string:'1.001'))", { any: { string: '1.001' } }], - - // Non-JSON strings that should return as string - ['undefined', 'undefined'], - ['not_json', 'not_json'], - ['plainstring', 'plainstring'], - ['a|b', 'a|b'], - ['foo bar#', 'foo bar#'] ])( - 'when searchStringFragment is %p, returns %p', - (searchStringFragment, expected) => { - const result = parseSearchFragment(searchStringFragment) - expect(result).toEqual(expected) + 'entry %p serializes to %p, parses to %p', + (entry, expectedSerialized, expectedParsedEntry) => { + const serialized = serializeSimpleSearchEntry(entry) + expect(serialized).toEqual(expectedSerialized) + expect( + serialized[1] === undefined + ? null + : parseSimpleSearchEntry(serialized[1]) + ).toEqual(expectedParsedEntry === null ? null : expectedParsedEntry[1]) } ) }) describe(`${parseSearch.name}`, () => { it.each([ - ['', {}], - ['?', {}], - [ - '?arr=(1,2)', - { - arr: [1, 2] - } - ], - ['?key1=value1&key2=', { key1: 'value1', key2: null }], - ['?key1=value1&key2=value2', { key1: 'value1', key2: 'value2' }], + ['?', {}, ''], + ['?=&&', {}, ''], + ['?=undefined', {}, ''], + ['?foo=', { foo: '' }, '?foo='], + ['??foo', { '?foo': '' }, '?%3Ffoo='], [ - '?key1=(foo:bar)&filters=((is,screen,(Mobile,Desktop)))', - { - key1: { foo: 'bar' }, - filters: [['is', 'screen', ['Mobile', 'Desktop']]] - } - ], - [ - '?filters=((is,country,(US)))&labels=(US:United%2BStates)', - { - filters: [['is', 'country', ['US']]], - labels: { - US: 'United States' - } - } - ] - ])('when searchString is %p, returns %p', (searchString, expected) => { - const result = parseSearch(searchString) - expect(result).toEqual(expected) - }) -}) - -describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => { - it.each([ - ["?filters=((is,'props:browser_language',(en-US)))"], - [ - '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(/open-source-website-analytics)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10' - ], - [ - "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)" - ], - [ - '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' + '?f=is,visit:page,/any/page&f', + { filters: [['is', 'visit:page', ['/any/page']]] }, + '?f=is,visit:page,/any/page' ] ])( - `input %p is returned for ${stringifySearch.name}(${parseSearch.name}(input))`, - (searchString) => { - const searchRecord = parseSearch(searchString) - const reStringifiedSearch = stringifySearch(searchRecord) - expect(reStringifiedSearch).toEqual(searchString) + 'for search string %s, returns search record %p, which in turn stringifies to %s', + (searchString, expectedSearchRecord, expectedRestringifiedResult) => { + expect(parseSearch(searchString)).toEqual(expectedSearchRecord) + expect(stringifySearch(expectedSearchRecord)).toEqual( + expectedRestringifiedResult + ) } ) +}) +describe(`${stringifySearch.name}`, () => { it.each([ - // Corresponding test cases for objects parsed from realistic URLs - + [{}, ''], [ { filters: [['is', 'props:browser_language', ['en-US']]] }, - "?filters=((is,'props:browser_language',(en-US)))" + '?f=is,props:browser_language,en-US' ], [ { filters: [ ['contains', 'utm_term', ['_']], ['is', 'screen', ['Desktop', 'Tablet']], - ['is', 'page', ['/open-source/analytics/encoded-hash%23']] + [ + 'is', + 'page', + ['/open-source/analytics/encoded-hash%23', '/unencoded-hash#'] + ] ], period: 'custom', keybindHint: 'A', @@ -213,7 +187,7 @@ describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each o from: '2024-08-08', to: '2024-08-10' }, - '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(%252Fopen-source%252Fanalytics%252Fencoded-hash%252523)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10' + '?f=contains,utm_term,_&f=is,screen,Desktop,Tablet&f=is,page,/open-source/analytics/encoded-hash%2523,/unencoded-hash%23&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10' ], [ { @@ -221,31 +195,70 @@ describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each o ['is', 'props:browser_language', ['en-US']], ['is', 'country', ['US']], ['is', 'os', ['iOS']], - ['is', 'os_version', ['17.3']], + ['is', 'os_version', ['17.3', '16.0']], ['is', 'page', ['/:dashboard/settings/general']] ], labels: { US: 'United States' } }, - "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)" - ], - [ - { - filters: [ - ['is', 'utm_source', ['hackernewsletter']], - ['is', 'utm_campaign', ['profile']] - ], - period: 'day', - keybindHint: 'D' - }, - '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' + '?f=is,props:browser_language,en-US&f=is,country,US&f=is,os,iOS&f=is,os_version,17.3,16.0&f=is,page,/:dashboard/settings/general&l=US,United%20States' ] - ])( - `for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`, - (searchRecord, expected) => { - const searchString = stringifySearch(searchRecord) - const parsedSearchRecord = parseSearch(searchString) - expect(parsedSearchRecord).toEqual(searchRecord) - expect(searchString).toEqual(expected) - } - ) + ])('works as expected', (searchRecord, expectedSearchString) => { + expect(stringifySearch(searchRecord)).toEqual(expectedSearchString) + expect(parseSearch(expectedSearchString)).toEqual(searchRecord) + }) +}) + +describe(`${getRedirectTarget.name}`, () => { + it.each([ + [''], + ['?auth=_Y6YOjUl2beUJF_XzG1hk&theme=light&background=%23ee00ee'], + ['?keybindHint=Escape&with_imported=true'], + ['?f=is,page,/blog/:category/:article-name&date=2024-10-10&period=day'], + ['?f=is,country,US&l=US,United%20States'] + ])('for modern search %p returns null', (search) => { + expect( + getRedirectTarget({ + pathname: '/example.com%2Fdeep%2Fpath', + search + } as Location) + ).toBeNull() + }) + + it('returns updated URL for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => { + const pathname = '/' + const search = + '?filters=((is,exit_page,(/plausible.io)),(is,source,(Brave)),(is,city,(993800)))&labels=(993800:Johannesburg)' + const expectedUpdatedSearch = + '?f=is,exit_page,/plausible.io&f=is,source,Brave&f=is,city,993800&l=993800,Johannesburg&r=v2' + expect( + getRedirectTarget({ + pathname, + search + } as Location) + ).toEqual(`${pathname}${expectedUpdatedSearch}`) + expect( + getRedirectTarget({ + pathname, + search: expectedUpdatedSearch + } as Location) + ).toBeNull() + }) + + it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => { + const pathname = '/' + const search = '?page=/docs' + const expectedUpdatedSearch = '?f=is,page,/docs&r=v1' + expect( + getRedirectTarget({ + pathname, + search + } as Location) + ).toEqual(`${pathname}${expectedUpdatedSearch}`) + expect( + getRedirectTarget({ + pathname, + search: expectedUpdatedSearch + } as Location) + ).toBeNull() + }) }) diff --git a/assets/js/dashboard/util/url-search-params.ts b/assets/js/dashboard/util/url-search-params.ts new file mode 100644 index 000000000000..b982202675fa --- /dev/null +++ b/assets/js/dashboard/util/url-search-params.ts @@ -0,0 +1,269 @@ +/** @format */ +import { Filter, FilterClauseLabels } from '../query' +import { v1 } from './url-search-params-v1' +import { v2 } from './url-search-params-v2' + +/** + * These charcters are not URL encoded to have more readable URLs. + * Browsers seem to handle this just fine. + * `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param`` + */ +const NOT_URL_ENCODED_CHARACTERS = ':/' + +export const FILTER_URL_PARAM_NAME = 'f' + +const LABEL_URL_PARAM_NAME = 'l' + +const REDIRECTED_SEARCH_PARAM_NAME = 'r' + +/** + * This function is able to serialize for URL simple params @see serializeSimpleSearchEntry as well + * two complex params, labels and filters. + */ +export function stringifySearch( + searchRecord: Record +): '' | string { + const { filters, labels, ...rest } = searchRecord ?? {} + const definedSearchEntries = Object.entries(rest) + .map(serializeSimpleSearchEntry) + .filter(isSearchEntryDefined) + .map(([k, v]) => `${k}=${v}`) + + if (!Array.isArray(filters) || !filters.length) { + return definedSearchEntries.length + ? `?${definedSearchEntries.join('&')}` + : '' + } + + const serializedFilters = Array.isArray(filters) + ? filters.map((f) => `${FILTER_URL_PARAM_NAME}=${serializeFilter(f)}`) + : [] + + const serializedLabels = Object.entries(labels ?? {}).map( + (entry) => `${LABEL_URL_PARAM_NAME}=${serializeLabelsEntry(entry)}` + ) + + return `?${serializedFilters.concat(serializedLabels).concat(definedSearchEntries).join('&')}` +} + +export function normalizeSearchString(searchString: string): string { + return searchString.startsWith('?') ? searchString.slice(1) : searchString +} + +export function parseSearch(searchString: string): Record { + const searchRecord: Record = {} + const filters: Filter[] = [] + const labels: FilterClauseLabels = {} + + const normalizedSearchString = normalizeSearchString(searchString) + + if (!normalizedSearchString.length) { + return searchRecord + } + + const meaningfulParams = normalizedSearchString + .split('&') + .filter((i) => i.length > 0) + + for (const param of meaningfulParams) { + const [key, rawValue = ''] = param.split('=') + switch (key) { + case FILTER_URL_PARAM_NAME: { + const filter = parseFilter(rawValue) + if (filter.length === 3 && filter[2].length) { + filters.push(filter) + } + break + } + case LABEL_URL_PARAM_NAME: { + const [labelKey, labelValue] = parseLabelsEntry(rawValue) + if (labelKey.length && labelValue.length) { + labels[labelKey] = labelValue + } + break + } + case '': { + break + } + default: { + const parsedValue = parseSimpleSearchEntry(rawValue) + if (parsedValue !== null) { + searchRecord[decodeURIComponent(key)] = parsedValue + } + } + } + } + + return { + ...searchRecord, + ...(filters.length && { filters }), + ...(Object.keys(labels).length && { labels }) + } +} + +/** + * Serializes and flattens @see FilterClauseLabels entries. + * Examples: + * ["US","United States"] -> "US,United%20States" + * ["US-CA","California"] -> "US-CA,California" + * ["5391959","San Francisco"] -> "5391959,San%20Francisco" + */ +export function serializeLabelsEntry([labelKey, labelValue]: [string, string]) { + return `${encodeURIComponentPermissive(labelKey, NOT_URL_ENCODED_CHARACTERS)},${encodeURIComponentPermissive(labelValue, NOT_URL_ENCODED_CHARACTERS)}` +} + +/** + * Parses the output of @see serializeLabelsEntry back to labels object entry. + */ +export function parseLabelsEntry( + labelKeyValueString: string +): [string, string] { + const [key, value] = labelKeyValueString.split(',') + return [decodeURIComponent(key), decodeURIComponent(value)] +} + +/** + * Serializes and flattens filters array item. + * Examples: + * ["is", "entry_page", ["/blog", "/news"]] -> "is,entry_page,/blog,/news" + */ +export function serializeFilter([operator, dimension, clauses]: Filter) { + const serializedFilter = [ + encodeURIComponentPermissive(operator, NOT_URL_ENCODED_CHARACTERS), + encodeURIComponentPermissive(dimension, NOT_URL_ENCODED_CHARACTERS), + ...clauses.map((clause) => + encodeURIComponentPermissive( + clause.toString(), + NOT_URL_ENCODED_CHARACTERS + ) + ) + ].join(',') + return serializedFilter +} + +/** + * Parses the output of @see serializeFilter back to filters array item. + */ +export function parseFilter(filterString: string): Filter { + const [operator, dimension, ...unparsedClauses] = filterString.split(',') + return [ + decodeURIComponent(operator), + decodeURIComponent(dimension), + unparsedClauses.map(decodeURIComponent) + ] +} + +/** + * Encodes for URL simple search param values. + * Encodes numbers and number-like strings as indistinguishable strings. Parse treats them as strings. + * Encodes booleans and strings "true" and "false" as indistinguishable strings. Parse treats these as booleans. + * Unifies unhandleable complex search entries like undefined, null, objects and arrays as undefined. + * Complex URL params must be handled separately. + */ +export function serializeSimpleSearchEntry([key, value]: [string, unknown]): [ + string, + undefined | string +] { + if (value === undefined || value === null || typeof value === 'object') { + return [key, undefined] + } + return [ + encodeURIComponentPermissive(key, ',:/'), + encodeURIComponentPermissive(value.toString(), ',:/') + ] +} + +/** + * Parses output of @see serializeSimpleSearchEntry. + */ +export function parseSimpleSearchEntry( + searchParamValue: string +): null | string | boolean { + if (searchParamValue === 'true') { + return true + } + if (searchParamValue === 'false') { + return false + } + return decodeURIComponent(searchParamValue) +} + +export function encodeURIComponentPermissive( + input: string, + permittedCharacters: string +): string { + return Array.from(permittedCharacters) + .map((character) => [encodeURIComponent(character), character]) + .reduce( + (acc, [encodedCharacter, character]) => + /* @ts-expect-error API supposedly not present in compilation target, but works in major browsers */ + acc.replaceAll(encodedCharacter, character), + encodeURIComponent(input) + ) +} + +export function isSearchEntryDefined( + entry: [string, undefined | string] +): entry is [string, string] { + return entry[1] !== undefined +} + +function isAlreadyRedirected(searchParams: URLSearchParams) { + return ['v1', 'v2'].includes(searchParams.get(REDIRECTED_SEARCH_PARAM_NAME)!) +} + +/** + Dashboard state is kept on the URL for people to be able to link to what that they see. + Because dashboard state is a complex object, in the interest of readable URLs, custom serialization and parsing is in place. + + Versions + * v1: @see v1 + A custom encoding schema was used for filters, (e.g. "?page=/blog"). + This was not flexible enough and diverged from how we represented filters in the code. + + * v2: @see v2 + jsonurl library was used to serialize the state. + The links from this solution didn't always auto-sense across all platforms (e.g. Twitter), cutting off too soon and leading users to broken dashboards. + + * current version: this module. + Custom encoding. + + The purpose of this function is to redirect users from one of the previous versions to the current version, + so previous dashboard links still work. +*/ +export function getRedirectTarget(windowLocation: Location): null | string { + const searchParams = new URLSearchParams(windowLocation.search) + if (isAlreadyRedirected(searchParams)) { + return null + } + const isCurrentVersion = searchParams.get(FILTER_URL_PARAM_NAME) + if (isCurrentVersion) { + return null + } + + const isV2 = v2.isV2(searchParams) + if (isV2) { + return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}` + } + + const searchRecord = v2.parseSearch(windowLocation.search) + const isV1 = v1.isV1(searchRecord) + + if (!isV1) { + return null + } + + return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}` +} + +/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */ +export function redirectForLegacyParams( + windowLocation: Location, + windowHistory: History +) { + const redirectTargetURL = getRedirectTarget(windowLocation) + if (redirectTargetURL === null) { + return + } + windowHistory.pushState({}, '', redirectTargetURL) +} diff --git a/assets/js/dashboard/util/url.ts b/assets/js/dashboard/util/url.ts index 2ce9def2e32e..5e77800e6ea1 100644 --- a/assets/js/dashboard/util/url.ts +++ b/assets/js/dashboard/util/url.ts @@ -1,5 +1,4 @@ /* @format */ -import JsonURL from '@jsonurl/jsonurl' import { PlausibleSite } from '../site-context' export function apiPath( @@ -72,86 +71,6 @@ export function trimURL(url: string, maxLength: number): string { } } -export function encodeURIComponentPermissive(input: string): string { - return ( - encodeURIComponent(input) - /* @ts-expect-error API supposedly not present in compilation target */ - .replaceAll('%2C', ',') - .replaceAll('%3A', ':') - .replaceAll('%2F', '/') - ) -} - -export function encodeSearchParamEntry([k, v]: [string, string]): string { - return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}` -} - -export function isSearchEntryDefined( - entry: [string, undefined | string] -): entry is [string, string] { - return entry[1] !== undefined -} - -export function stringifySearch( - searchRecord: Record -): '' | string { - const definedSearchEntries = Object.entries(searchRecord || {}) - .map(stringifySearchEntry) - .filter(isSearchEntryDefined) - - const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry) - - return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : '' -} - -export function stringifySearchEntry([key, value]: [string, unknown]): [ - string, - undefined | string -] { - const isEmptyObjectOrArray = - typeof value === 'object' && - value !== null && - Object.entries(value).length === 0 - if (value === undefined || value === null || isEmptyObjectOrArray) { - return [key, undefined] - } - - return [key, JsonURL.stringify(value)] -} - -export function parseSearchFragment( - searchStringFragment: string -): null | unknown { - if (searchStringFragment === '') { - return null - } - // tricky: the search string fragment is already decoded due to URLSearchParams intermediate (see tests), - // and these symbols are unparseable - const fragmentWithReEncodedSymbols = searchStringFragment - /* @ts-expect-error API supposedly not present in compilation target */ - .replaceAll('=', encodeURIComponent('=')) - .replaceAll('#', encodeURIComponent('#')) - .replaceAll('|', encodeURIComponent('|')) - .replaceAll(' ', encodeURIComponent(' ')) - - try { - return JsonURL.parse(fragmentWithReEncodedSymbols) - } catch (error) { - console.error( - `Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`, - error - ) - return null - } -} - -export function parseSearch(searchString: string): Record { - const urlSearchParams = new URLSearchParams(searchString) - const searchRecord: Record = {} - urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v))) - return searchRecord -} - export function maybeEncodeRouteParam(param: string) { return param.includes('/') ? encodeURIComponent(param) : param }