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/query.test.ts b/assets/js/dashboard/query.test.ts deleted file mode 100644 index 9d3c208404d0..000000000000 --- a/assets/js/dashboard/query.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** @format */ - -import { maybeGetRedirectTargetFromLegacySearchParams } from './query' - -describe(`${maybeGetRedirectTargetFromLegacySearchParams.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( - maybeGetRedirectTargetFromLegacySearchParams({ - pathname: '/example.com%2Fdeep%2Fpath', - search - } as Location) - ).toBeNull() - }) - - it('returns updated URL for jsonurl style filters, 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' - expect( - maybeGetRedirectTargetFromLegacySearchParams({ - pathname, - search - } as Location) - ).toEqual(`${pathname}${expectedUpdatedSearch}`) - expect( - maybeGetRedirectTargetFromLegacySearchParams({ - pathname, - search: expectedUpdatedSearch - } as Location) - ).toBeNull() - }) - - it('returns updated URL for page=... style filters, 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' - expect( - maybeGetRedirectTargetFromLegacySearchParams({ - pathname, - search - } as Location) - ).toEqual(`${pathname}${expectedUpdatedSearch}`) - expect( - maybeGetRedirectTargetFromLegacySearchParams({ - pathname, - search: expectedUpdatedSearch - } as Location) - ).toBeNull() - }) -}) diff --git a/assets/js/dashboard/query.ts b/assets/js/dashboard/query.ts index cda1a4fdc211..cb34a236648f 100644 --- a/assets/js/dashboard/query.ts +++ b/assets/js/dashboard/query.ts @@ -9,21 +9,11 @@ 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' import { Dayjs } from 'dayjs' -import { legacyParseSearch } from './util/legacy-jsonurl-url-search-params' -import { - FILTER_URL_PARAM_NAME, - stringifySearch -} from './util/url-search-params' export type FilterClause = string | number @@ -71,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 @@ -104,79 +71,6 @@ export function postProcessFilters(filters: Array): Array { }) } -export function maybeGetRedirectTargetFromLegacySearchParams( - windowLocation: Location -): null | string { - const searchParams = new URLSearchParams(windowLocation.search) - const isCurrentVersion = searchParams.get(FILTER_URL_PARAM_NAME) - if (isCurrentVersion) { - return null - } - - const isJsonURLVersion = searchParams.get('filters') - if (isJsonURLVersion) { - return `${windowLocation.pathname}${stringifySearch(legacyParseSearch(windowLocation.search))}` - } - - const searchRecord = legacyParseSearch(windowLocation.search) - const searchRecordEntries = Object.entries( - legacyParseSearch(windowLocation.search) - ) - - const isBeforeJsonURLVersion = searchRecordEntries.some( - ([k]) => k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k) - ) - - if (!isBeforeJsonURLVersion) { - return null - } - - const changedSearchRecordEntries = [] - const filters: DashboardQuery['filters'] = [] - let labels: DashboardQuery['labels'] = {} - - for (const [key, value] of searchRecordEntries) { - 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 && 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 { - changedSearchRecordEntries.push([key, value]) - } - } - - if (searchRecord['props']) { - filters.push(...(parseLegacyPropsFilter(searchRecord['props']) as Filter[])) - } - changedSearchRecordEntries.push(['filters', filters], ['labels', labels]) - return `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}` -} - -/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */ -export function filtersBackwardsCompatibilityRedirect( - windowLocation: Location, - windowHistory: History -) { - const redirectTargetURL = - maybeGetRedirectTargetFromLegacySearchParams(windowLocation) - if (redirectTargetURL === null) { - return - } - windowHistory.pushState({}, '', redirectTargetURL) -} - // 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/legacy-jsonurl-url-search-params.test.ts b/assets/js/dashboard/util/url-search-params-v2.test.ts similarity index 81% rename from assets/js/dashboard/util/legacy-jsonurl-url-search-params.test.ts rename to assets/js/dashboard/util/url-search-params-v2.test.ts index fdc86930b9ed..72ee4182e4c1 100644 --- a/assets/js/dashboard/util/legacy-jsonurl-url-search-params.test.ts +++ b/assets/js/dashboard/util/url-search-params-v2.test.ts @@ -1,19 +1,20 @@ /** @format */ import JsonURL from '@jsonurl/jsonurl' -import { - legacyParseSearch, - legacyParseSearchFragment, - legacyStringifySearch, - legacyStringifySearchEntry -} from './legacy-jsonurl-url-search-params' +import { v2 } from './url-search-params-v2' -beforeEach(() => { - // Silence logs in tests - jest.spyOn(console, 'error').mockImplementation(jest.fn()) -}) +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(() => @@ -22,7 +23,7 @@ describe('using json URL parsing with URLSearchParams intermediate', () => { }) }) -describe(`${legacyStringifySearchEntry.name}`, () => { +describe(`${stringifySearchEntry.name}`, () => { it.each<[[string, unknown], [string, string | undefined]]>([ [ ['any-key', {}], @@ -53,12 +54,12 @@ describe(`${legacyStringifySearchEntry.name}`, () => { ['filters', "((is,'props:foo:bar',(one,two)))"] ] ])('when input is %p, returns %p', (input, expected) => { - const result = legacyStringifySearchEntry(input) + const result = stringifySearchEntry(input) expect(result).toEqual(expected) }) }) -describe(`${legacyParseSearchFragment.name}`, () => { +describe(`${parseSearchFragment.name}`, () => { it.each([ ['', null], ['("foo":)', null], @@ -83,13 +84,13 @@ describe(`${legacyParseSearchFragment.name}`, () => { ])( 'when searchStringFragment is %p, returns %p', (searchStringFragment, expected) => { - const result = legacyParseSearchFragment(searchStringFragment) + const result = parseSearchFragment(searchStringFragment) expect(result).toEqual(expected) } ) }) -describe(`${legacyParseSearch.name}`, () => { +describe(`${parseSearch.name}`, () => { it.each([ ['', {}], ['?', {}], @@ -118,12 +119,12 @@ describe(`${legacyParseSearch.name}`, () => { } ] ])('when searchString is %p, returns %p', (searchString, expected) => { - const result = legacyParseSearch(searchString) + const result = parseSearch(searchString) expect(result).toEqual(expected) }) }) -describe(`${legacyStringifySearch.name} and ${legacyParseSearch.name} are inverses of each other`, () => { +describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => { it.each([ ["?filters=((is,'props:browser_language',(en-US)))"], [ @@ -136,10 +137,10 @@ describe(`${legacyStringifySearch.name} and ${legacyParseSearch.name} are invers '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' ] ])( - `input %p is returned for ${legacyParseSearch.name}(${legacyParseSearch.name}(input))`, + `input %p is returned for ${parseSearch.name}(${parseSearch.name}(input))`, (searchString) => { - const searchRecord = legacyParseSearch(searchString) - const reStringifiedSearch = legacyStringifySearch(searchRecord) + const searchRecord = parseSearch(searchString) + const reStringifiedSearch = stringifySearch(searchRecord) expect(reStringifiedSearch).toEqual(searchString) } ) @@ -194,10 +195,10 @@ describe(`${legacyStringifySearch.name} and ${legacyParseSearch.name} are invers '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D' ] ])( - `for input %p, ${legacyStringifySearch.name}(input) returns %p and ${legacyParseSearch.name}(${legacyStringifySearch.name}(input)) returns the original input`, + `for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`, (searchRecord, expected) => { - const searchString = legacyStringifySearch(searchRecord) - const parsedSearchRecord = legacyParseSearch(searchString) + const searchString = stringifySearch(searchRecord) + const parsedSearchRecord = parseSearch(searchString) expect(parsedSearchRecord).toEqual(searchRecord) expect(searchString).toEqual(expected) } diff --git a/assets/js/dashboard/util/legacy-jsonurl-url-search-params.ts b/assets/js/dashboard/util/url-search-params-v2.ts similarity index 74% rename from assets/js/dashboard/util/legacy-jsonurl-url-search-params.ts rename to assets/js/dashboard/util/url-search-params-v2.ts index ede93c60b28b..98377f8971a6 100644 --- a/assets/js/dashboard/util/legacy-jsonurl-url-search-params.ts +++ b/assets/js/dashboard/util/url-search-params-v2.ts @@ -7,6 +7,10 @@ import { const permittedCharactersInURLParamKeyValue = ',:/' +function isV2(urlSearchParams: URLSearchParams): boolean { + return !!urlSearchParams.get('filters') +} + function encodeSearchParamEntry([k, v]: [string, string]): string { return [k, v] .map((s) => @@ -15,11 +19,9 @@ function encodeSearchParamEntry([k, v]: [string, string]): string { .join('=') } -export function legacyStringifySearch( - searchRecord: Record -): '' | string { +function stringifySearch(searchRecord: Record): '' | string { const definedSearchEntries = Object.entries(searchRecord || {}) - .map(legacyStringifySearchEntry) + .map(stringifySearchEntry) .filter(isSearchEntryDefined) const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry) @@ -27,7 +29,7 @@ export function legacyStringifySearch( return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : '' } -export function legacyStringifySearchEntry([key, value]: [string, unknown]): [ +function stringifySearchEntry([key, value]: [string, unknown]): [ string, undefined | string ] { @@ -42,9 +44,7 @@ export function legacyStringifySearchEntry([key, value]: [string, unknown]): [ return [key, JsonURL.stringify(value)] } -export function legacyParseSearchFragment( - searchStringFragment: string -): null | unknown { +function parseSearchFragment(searchStringFragment: string): null | unknown { if (searchStringFragment === '') { return null } @@ -68,13 +68,17 @@ export function legacyParseSearchFragment( } } -export function legacyParseSearch( - searchString: string -): Record { +function parseSearch(searchString: string): Record { const urlSearchParams = new URLSearchParams(searchString) const searchRecord: Record = {} - urlSearchParams.forEach( - (v, k) => (searchRecord[k] = legacyParseSearchFragment(v)) - ) + 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 980e6f1ef02c..95fd65d9fac5 100644 --- a/assets/js/dashboard/util/url-search-params.test.ts +++ b/assets/js/dashboard/util/url-search-params.test.ts @@ -4,6 +4,7 @@ import { Filter } from '../query' import { encodeURIComponentPermissive, isSearchEntryDefined, + getRedirectTarget, parseFilter, parseLabelsEntry, parseSearch, @@ -206,3 +207,58 @@ describe(`${stringifySearch.name}`, () => { 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 index 6506284732cd..b982202675fa 100644 --- a/assets/js/dashboard/util/url-search-params.ts +++ b/assets/js/dashboard/util/url-search-params.ts @@ -1,5 +1,7 @@ /** @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. @@ -12,6 +14,8 @@ 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. @@ -203,3 +207,63 @@ export function isSearchEntryDefined( ): 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) +}