From 28cf3ff2b2c23d81918c57057464db2f2584dcdd Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 24 Jul 2024 15:14:00 +0300 Subject: [PATCH] Install newest @tanstack/react-router (#4384) * Install @tanstack/react-router * Fix with imported switch * Refactor redirect to new search parser * Deregister timeout in ComparisonInput * Comment uses of window.location * Fix with imported switch appearing in realtime dashboard * Handle not found routes more gracefully --- assets/js/dashboard.js | 14 +- assets/js/dashboard/comparison-input.js | 85 ++++--- assets/js/dashboard/datepicker.js | 51 ++-- assets/js/dashboard/filters.js | 56 +++-- assets/js/dashboard/query-context.js | 7 +- assets/js/dashboard/query.js | 131 +++++----- assets/js/dashboard/router.js | 225 +++++++++++++----- assets/js/dashboard/site-switcher.js | 1 + .../dashboard/stats/behaviours/conversions.js | 3 +- .../stats/behaviours/goal-conversions.js | 3 +- assets/js/dashboard/stats/behaviours/index.js | 4 +- assets/js/dashboard/stats/behaviours/props.js | 3 +- assets/js/dashboard/stats/current-visitors.js | 11 +- assets/js/dashboard/stats/graph/line-graph.js | 13 +- .../js/dashboard/stats/graph/visitor-graph.js | 9 +- .../stats/graph/with-imported-switch.js | 45 ++-- assets/js/dashboard/stats/locations/index.js | 11 +- assets/js/dashboard/stats/locations/map.js | 6 +- .../dashboard/stats/modals/breakdown-modal.js | 5 +- .../js/dashboard/stats/modals/filter-modal.js | 51 ++-- .../dashboard/stats/modals/locations-modal.js | 12 +- assets/js/dashboard/stats/modals/modal.js | 16 +- assets/js/dashboard/stats/modals/props.js | 8 +- .../stats/modals/referrer-drilldown.js | 11 +- assets/js/dashboard/stats/modals/sources.js | 9 +- assets/js/dashboard/stats/more-link.js | 6 +- assets/js/dashboard/stats/pages/index.js | 7 +- assets/js/dashboard/stats/reports/list.js | 22 +- .../dashboard/stats/sources/referrer-list.js | 3 +- .../dashboard/stats/sources/search-terms.js | 3 +- .../js/dashboard/stats/sources/source-list.js | 17 +- assets/js/dashboard/util/storage.js | 2 + assets/js/dashboard/util/url.js | 93 ++++---- assets/package-lock.json | 147 ++++++------ assets/package.json | 2 +- 35 files changed, 612 insertions(+), 480 deletions(-) diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index b1077ce9c..62f43e8fb 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -2,7 +2,8 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import 'url-search-params-polyfill'; -import Router from './dashboard/router' +import { RouterProvider } from '@tanstack/react-router'; +import { createAppRouter } from './dashboard/router' import ErrorBoundary from './dashboard/error-boundary' import * as api from './dashboard/api' import * as timer from './dashboard/util/realtime-update-timer' @@ -22,13 +23,18 @@ if (container) { api.setSharedLinkAuth(sharedLinkAuth) } - filtersBackwardsCompatibilityRedirect() - + try { + filtersBackwardsCompatibilityRedirect(window.location) + } catch (e) { + console.error('Error redirecting in a backwards compatible way', e) + } + + const router = createAppRouter(site); const app = ( - + diff --git a/assets/js/dashboard/comparison-input.js b/assets/js/dashboard/comparison-input.js index 36861d6c5..eb652af7d 100644 --- a/assets/js/dashboard/comparison-input.js +++ b/assets/js/dashboard/comparison-input.js @@ -1,6 +1,6 @@ -import React, { Fragment } from 'react' -import { withRouter } from 'react-router-dom' +import React, { Fragment, useState, useRef, useEffect } from 'react' import { navigateToQuery } from './query' +import { useNavigate } from '@tanstack/react-router' import { Menu, Transition } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' @@ -21,40 +21,55 @@ const DEFAULT_COMPARISON_MODE = 'previous_period' export const COMPARISON_DISABLED_PERIODS = ['realtime', 'all'] -export const getStoredMatchDayOfWeek = function (domain) { - return storage.getItem(`comparison_match_day_of_week__${domain}`) || 'true' +const getMatchDayOfWeekStorageKey = (domain) => storage.getDomainScopedStorageKey('comparison_match_day_of_week', domain) + +const storeMatchDayOfWeek = function (domain, matchDayOfWeek) { + storage.setItem(getMatchDayOfWeekStorageKey(domain), matchDayOfWeek.toString()); } -export const getStoredComparisonMode = function (domain) { - const mode = storage.getItem(`comparison_mode__${domain}`) - if (Object.keys(COMPARISON_MODES).includes(mode)) { - return mode - } else { - return null +export const getStoredMatchDayOfWeek = function (domain, fallbackValue) { + const storedValue = storage.getItem(getMatchDayOfWeekStorageKey(domain)); + if (storedValue === 'true') { + return true; + } + if (storedValue === 'false') { + return false; } + return fallbackValue; +} + +const getComparisonModeStorageKey = (domain) => storage.getDomainScopedStorageKey('comparison_mode', domain) + +export const getStoredComparisonMode = function (domain, fallbackValue) { + const storedValue = storage.getItem(getComparisonModeStorageKey(domain)) + if (Object.keys(COMPARISON_MODES).includes(storedValue)) { + return storedValue + } + + return fallbackValue } const storeComparisonMode = function (domain, mode) { if (mode == "custom") return - storage.setItem(`comparison_mode__${domain}`, mode) + storage.setItem(getComparisonModeStorageKey(domain), mode) } export const isComparisonEnabled = function (mode) { return mode && mode !== "off" } -export const toggleComparisons = function (history, query, site) { +export const toggleComparisons = function (navigate, query, site) { if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return if (isComparisonEnabled(query.comparison)) { storeComparisonMode(site.domain, "off") - navigateToQuery(history, query, { comparison: "off" }) + navigateToQuery(navigate, query, { comparison: "off" }) } else { - const storedMode = getStoredComparisonMode(site.domain) + const storedMode = getStoredComparisonMode(site.domain, null) const newMode = isComparisonEnabled(storedMode) ? storedMode : DEFAULT_COMPARISON_MODE storeComparisonMode(site.domain, newMode) - navigateToQuery(history, query, { comparison: newMode }) + navigateToQuery(navigate, query, { comparison: newMode }) } } @@ -86,12 +101,13 @@ function ComparisonModeOption({ label, value, isCurrentlySelected, updateMode, s ) } -function MatchDayOfWeekInput({ history }) { +function MatchDayOfWeekInput() { + const navigate = useNavigate(); const site = useSiteContext() const { query } = useQueryContext() const click = (matchDayOfWeek) => { - storage.setItem(`comparison_match_day_of_week__${site.domain}`, matchDayOfWeek.toString()) - navigateToQuery(history, query, { match_day_of_week: matchDayOfWeek.toString() }) + storeMatchDayOfWeek(site.domain, matchDayOfWeek) + navigateToQuery(navigate, query, { match_day_of_week: matchDayOfWeek }) } const buttonClass = (hover, selected) => @@ -116,15 +132,28 @@ function MatchDayOfWeekInput({ history }) { } -const ComparisonInput = function ({ history }) { +function ComparisonInput() { const { query } = useQueryContext(); const site = useSiteContext(); + const navigate = useNavigate(); + const calendar = useRef(null) + + const [uiMode, setUiMode] = useState("menu") + + useEffect(() => { + let timeout = null; + if (uiMode == "datepicker") { + timeout = setTimeout(() => calendar.current?.flatpickr.open(), 100) + } + return () => timeout && clearTimeout(timeout) + }, [uiMode]) + if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null if (!isComparisonEnabled(query.comparison)) return null const updateMode = (mode, from = null, to = null) => { storeComparisonMode(site.domain, mode) - navigateToQuery(history, query, { comparison: mode, compare_from: from, compare_to: to }) + navigateToQuery(navigate, query, { comparison: mode, compare_from: from, compare_to: to }) } const buildLabel = (site, query) => { @@ -135,18 +164,6 @@ const ComparisonInput = function ({ history }) { } } - // eslint-disable-next-line react-hooks/rules-of-hooks - const calendar = React.useRef(null) - - // eslint-disable-next-line react-hooks/rules-of-hooks - const [uiMode, setUiMode] = React.useState("menu") - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { - if (uiMode == "datepicker") { - setTimeout(() => calendar.current.flatpickr.open(), 100) - } - }, [uiMode]) - const flatpickrOptions = { mode: 'range', showMonths: 1, @@ -186,7 +203,7 @@ const ComparisonInput = function ({ history }) { {Object.keys(COMPARISON_MODES).map((key) => ComparisonModeOption({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode }))} {query.comparison !== "custom" &&
- +
} @@ -202,4 +219,4 @@ const ComparisonInput = function ({ history }) { ) } -export default withRouter(ComparisonInput) +export default ComparisonInput diff --git a/assets/js/dashboard/datepicker.js b/assets/js/dashboard/datepicker.js index c4b8dc5d0..4b206c1b7 100644 --- a/assets/js/dashboard/datepicker.js +++ b/assets/js/dashboard/datepicker.js @@ -1,5 +1,5 @@ import React, { Fragment, useState, useEffect, useCallback, useRef } from "react"; -import { withRouter } from "react-router-dom"; +import { useNavigate } from "@tanstack/react-router"; import Flatpickr from "react-flatpickr"; import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { Transition } from '@headlessui/react'; @@ -70,7 +70,7 @@ function renderArrow(query, site, period, prevDate, nextDate) { return (
@@ -88,7 +88,7 @@ function renderArrow(query, site, period, prevDate, nextDate) { @@ -166,22 +166,23 @@ function DisplayPeriod() { return 'Realtime' } -function DatePicker({ history }) { +function DatePicker() { const { query } = useQueryContext(); const site = useSiteContext(); const [open, setOpen] = useState(false) const [mode, setMode] = useState('menu') const dropDownNode = useRef(null) const calendar = useRef(null) + const navigate = useNavigate(); const handleKeydown = useCallback((e) => { if (shouldIgnoreKeypress(e)) return true const newSearch = { - period: false, - from: false, - to: false, - date: false + period: null, + from: null, + to: null, + date: null }; const insertionDate = parseUTCDate(site.statsBegin); @@ -222,28 +223,28 @@ function DatePicker({ history }) { setOpen(false); const keybindings = { - d: { date: false, period: 'day' }, + d: { date: null, period: 'day' }, e: { date: formatISO(shiftDays(nowForSite(site), -1)), period: 'day' }, r: { period: 'realtime' }, - w: { date: false, period: '7d' }, - m: { date: false, period: 'month' }, - y: { date: false, period: 'year' }, - t: { date: false, period: '30d' }, - s: { date: false, period: '6mo' }, - l: { date: false, period: '12mo' }, - a: { date: false, period: 'all' }, + w: { date: null, period: '7d' }, + m: { date: null, period: 'month' }, + y: { date: null, period: 'year' }, + t: { date: null, period: '30d' }, + s: { date: null, period: '6mo' }, + l: { date: null, period: '12mo' }, + a: { date: null, period: 'all' }, } const redirect = keybindings[e.key.toLowerCase()] if (redirect) { - navigateToQuery(history, query, { ...newSearch, ...redirect }) + navigateToQuery(navigate, query, { ...newSearch, ...redirect, keybindHint: e.key.toUpperCase() }) } else if (e.key.toLowerCase() === 'x') { - toggleComparisons(history, query, site) + toggleComparisons(navigate, query, site) } else if (e.key.toLowerCase() === 'c') { setOpen(true) setMode('calendar') } else if (newSearch.date) { - navigateToQuery(history, query, newSearch); + navigateToQuery(navigate, query, newSearch); } }, [query]) @@ -274,9 +275,9 @@ function DatePicker({ history }) { [from, to] = [parseNaiveDate(from), parseNaiveDate(to)] if (from.isSame(to)) { - navigateToQuery(history, query, { period: 'day', date: formatISO(from), from: false, to: false }) + navigateToQuery(navigate, query, { period: 'day', date: formatISO(from), from: null, to: null }) } else { - navigateToQuery(history, query, { period: 'custom', date: false, from: formatISO(from), to: formatISO(to) }) + navigateToQuery(navigate, query, { period: 'custom', date: null, from: formatISO(from), to: formatISO(to) }) } } @@ -304,11 +305,11 @@ function DatePicker({ history }) { boldClass = query.period === period ? "font-bold" : ""; } - opts.date = opts.date ? formatISO(opts.date) : false; + opts.date = opts.date ? formatISO(opts.date) : null; return ( setOpen(false)} className={`${boldClass} px-4 py-2 text-sm leading-tight hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 flex items-center justify-between`} @@ -370,7 +371,7 @@ function DatePicker({ history }) {
{ - toggleComparisons(history, query, site) + toggleComparisons(navigate, query, site) setOpen(false) }} className="px-4 py-2 text-sm leading-tight hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer flex items-center justify-between"> @@ -450,4 +451,4 @@ function DatePicker({ history }) { ) } -export default withRouter(DatePicker); +export default DatePicker diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index cca858b70..fef22d613 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -1,5 +1,5 @@ import React, { Fragment, useEffect, useState } from 'react'; -import { Link, withRouter } from 'react-router-dom'; +import { Link, useNavigate } from '@tanstack/react-router'; import { AdjustmentsVerticalIcon, MagnifyingGlassIcon, XMarkIcon, PencilSquareIcon } from '@heroicons/react/20/solid'; import classNames from 'classnames'; import { Menu, Transition } from '@headlessui/react'; @@ -18,25 +18,26 @@ import { } from "./util/filters"; import { useQueryContext } from './query-context'; import { useSiteContext } from './site-context'; +import { filterRoute } from './router'; const WRAPSTATE = { unwrapped: 0, waiting: 1, wrapped: 2 } -function removeFilter(filterIndex, history, query) { +function removeFilter(filterIndex, navigate, query) { const newFilters = query.filters.filter((_filter, index) => filterIndex != index) const newLabels = cleanLabels(newFilters, query.labels) navigateToQuery( - history, + navigate, query, { filters: newFilters, labels: newLabels } ) } -function clearAllFilters(history, query) { +function clearAllFilters(navigate, query) { navigateToQuery( - history, + navigate, query, - { filters: false, labels: false } + { filters: null, labels: null } ); } @@ -53,16 +54,19 @@ function filterText(query, [operation, filterKey, clauses]) { throw new Error(`Unknown filter: ${filterKey}`) } -function renderDropdownFilter(filterIndex, filter, site, history, query) { +function renderDropdownFilter(filterIndex, filter, navigate, query) { const [_operation, filterKey, _clauses] = filter const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey + return (
search} className="group flex w-full justify-between items-center" style={{ width: 'calc(100% - 1.5rem)' }} > @@ -72,7 +76,7 @@ function renderDropdownFilter(filterIndex, filter, site, history, query) { removeFilter(filterIndex, history, query)} + onClick={() => removeFilter(filterIndex, navigate, query)} > @@ -81,12 +85,14 @@ function renderDropdownFilter(filterIndex, filter, site, history, query) { ) } -function filterDropdownOption(site, option) { +function filterDropdownOption(option) { return ( {({ active }) => ( search} className={classNames( active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100' : 'text-gray-800 dark:text-gray-300', 'block px-4 py-2 text-sm font-medium' @@ -99,7 +105,8 @@ function filterDropdownOption(site, option) { ) } -function DropdownContent({ history, wrapped }) { +function DropdownContent({ wrapped }) { + const navigate = useNavigate(); const site = useSiteContext(); const { query } = useQueryContext(); const [addingFilter, setAddingFilter] = useState(false); @@ -108,7 +115,7 @@ function DropdownContent({ history, wrapped }) { let filterModals = { ...FILTER_MODAL_TO_FILTER_GROUP } if (!site.propsAvailable) delete filterModals.props - return Object.keys(filterModals).map((option) => filterDropdownOption(site, option)) + return Object.keys(filterModals).map((option) => filterDropdownOption(option)) } return ( @@ -116,9 +123,9 @@ function DropdownContent({ history, wrapped }) {
setAddingFilter(true)}> + Add filter
- {query.filters.map((filter, index) => renderDropdownFilter(index, filter, site, history, query))} + {query.filters.map((filter, index) => renderDropdownFilter(index, filter, navigate, query))} -
clearAllFilters(history, query)}> +
clearAllFilters(navigate, query)}> Clear All Filters
@@ -126,7 +133,8 @@ function DropdownContent({ history, wrapped }) { ) } -function Filters({ history }) { +function Filters() { + const navigate = useNavigate(); const { query } = useQueryContext(); const [wrapped, setWrapped] = useState(WRAPSTATE.waiting) @@ -157,7 +165,7 @@ function Filters({ history }) { if (e.ctrlKey || e.metaKey || e.altKey) return if (e.key === 'Escape') { - clearAllFilters(history, query) + clearAllFilters(navigate, query) } } @@ -197,17 +205,16 @@ function Filters({ history }) { search} > {text} removeFilter(filterIndex, history, query)} + onClick={() => removeFilter(filterIndex, navigate, query)} > @@ -236,6 +243,7 @@ function Filters({ history }) { } function trackFilterMenu() { + // has to use window.location because Router location does not include protocol or hostname window.plausible && window.plausible('Filter Menu: Open', { u: `${window.location.protocol}//${window.location.hostname}/:dashboard` }) } @@ -268,7 +276,7 @@ function Filters({ history }) { className="rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 font-medium text-gray-800 dark:text-gray-200" > - +
@@ -301,4 +309,4 @@ function Filters({ history }) { ) } -export default withRouter(Filters); +export default Filters; diff --git a/assets/js/dashboard/query-context.js b/assets/js/dashboard/query-context.js index fbfa93327..555cc0af1 100644 --- a/assets/js/dashboard/query-context.js +++ b/assets/js/dashboard/query-context.js @@ -1,6 +1,6 @@ import React, { createContext, useMemo, useEffect, useContext, useState, useCallback } from "react"; import { parseQuery } from "./query"; -import { withRouter } from "react-router-dom"; +import { useLocation } from "@tanstack/react-router"; import { useMountedEffect } from "./custom-hooks"; import * as api from './api' import { useSiteContext } from "./site-context"; @@ -11,7 +11,8 @@ const QueryContext = createContext(queryContextDefaultValue) export const useQueryContext = () => { return useContext(QueryContext) } -function QueryContextProvider({ location, children }) { +export default function QueryContextProvider({ children }) { + const location = useLocation(); const site = useSiteContext(); const { search } = location; const query = useMemo(() => { @@ -36,5 +37,3 @@ function QueryContextProvider({ location, children }) { return {children} }; - -export default withRouter(QueryContextProvider); diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index fdc3688dc..6025be81c 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -1,7 +1,6 @@ -import React from 'react' -import { Link, withRouter } from 'react-router-dom' -import JsonURL from '@jsonurl/jsonurl' -import { PlausibleSearchParams, updatedQuery } from './util/url' +import React, {useCallback} from 'react' +import { Link, useNavigate } from '@tanstack/react-router' +import { parseSearch, stringifySearch } from './util/url' import { nowForSite } from './util/date' import * as storage from './util/storage' import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input' @@ -16,36 +15,36 @@ dayjs.extend(utc) const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '6mo', '12mo', 'year', 'all', 'custom'] -export function parseQuery(querystring, site) { - const q = new PlausibleSearchParams(querystring) - let period = q.get('period') +export function parseQuery(searchRecord, site) { + const getValue = (k) => searchRecord[k]; + let period = getValue('period') const periodKey = `period__${site.domain}` if (PERIODS.includes(period)) { - if (period !== 'custom' && period !== 'realtime') storage.setItem(periodKey, period) + if (period !== 'custom' && period !== 'realtime') {storage.setItem(periodKey, period)} } else if (storage.getItem(periodKey)) { period = storage.getItem(periodKey) } else { period = '30d' } - let comparison = q.get('comparison') || getStoredComparisonMode(site.domain) + let comparison = getValue('comparison') ?? getStoredComparisonMode(site.domain, null) if (COMPARISON_DISABLED_PERIODS.includes(period) || !isComparisonEnabled(comparison)) comparison = null - let matchDayOfWeek = q.get('match_day_of_week') || getStoredMatchDayOfWeek(site.domain) + let matchDayOfWeek = getValue('match_day_of_week') ?? getStoredMatchDayOfWeek(site.domain, true) return { period, comparison, - compare_from: q.get('compare_from') ? dayjs.utc(q.get('compare_from')) : undefined, - compare_to: q.get('compare_to') ? dayjs.utc(q.get('compare_to')) : undefined, - date: q.get('date') ? dayjs.utc(q.get('date')) : nowForSite(site), - from: q.get('from') ? dayjs.utc(q.get('from')) : undefined, - to: q.get('to') ? dayjs.utc(q.get('to')) : undefined, - match_day_of_week: matchDayOfWeek == 'true', - with_imported: q.get('with_imported') ? q.get('with_imported') === 'true' : true, - filters: parseJsonUrl(q.get('filters'), []), - labels: parseJsonUrl(q.get('labels'), {}) + compare_from: getValue('compare_from') ? dayjs.utc(getValue('compare_from')) : undefined, + compare_to: getValue('compare_to') ? dayjs.utc(getValue('compare_to')) : undefined, + date: getValue('date') ? dayjs.utc(getValue('date')) : nowForSite(site), + from: getValue('from') ? dayjs.utc(getValue('from')) : undefined, + to: getValue('to') ? dayjs.utc(getValue('to')) : undefined, + match_day_of_week: matchDayOfWeek === true, + with_imported: getValue('with_imported') ?? true, + filters: getValue('filters') || [], + labels: getValue('labels') || {} } } @@ -53,22 +52,17 @@ export function addFilter(query, filter) { return { ...query, filters: [...query.filters, filter] } } -export function navigateToQuery(history, queryFrom, newData) { + + +export function navigateToQuery(navigate, {period}, newPartialSearchRecord) { // if we update any data that we store in localstorage, make sure going back in history will // revert them - if (newData.period && newData.period !== queryFrom.period) { - history.replace({ search: updatedQuery({ period: queryFrom.period }) }) + if (newPartialSearchRecord.period && newPartialSearchRecord.period !== period) { + navigate({ search: (search) => ({ ...search, period: period }), replace: true }) } // then push the new query to the history - history.push({ search: updatedQuery(newData) }) -} - -function parseJsonUrl(value, defaultValue) { - if (!value) { - return defaultValue - } - return JsonURL.parse(value.replaceAll("=", "%3D")) + navigate({ search: (search) => ({ ...search, ...newPartialSearchRecord }) }) } const LEGACY_URL_PARAMETERS = { @@ -96,46 +90,44 @@ const LEGACY_URL_PARAMETERS = { // 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() { - const q = new PlausibleSearchParams(window.location.search) - const entries = Array.from(q.entries()) - +export function filtersBackwardsCompatibilityRedirect(windowLocation) { + const searchRecord = parseSearch(windowLocation.search) + const getValue = (k) => searchRecord[k]; + // New filters are used - no need to do anything - if (q.get("filters")) { + if (getValue("filters")) { return } - + + const changedSearchRecordEntries = []; let filters = [] let labels = {} - for (const [key, value] of entries) { + for (const [key, value] of Object.entries(searchRecord)) { if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) { const filter = parseLegacyFilter(key, value) filters.push(filter) - q.delete(key) const labelsKey = LEGACY_URL_PARAMETERS[key] - if (labelsKey && q.get(labelsKey)) { + if (labelsKey && getValue(labelsKey)) { const clauses = filter[2] - const labelsValues = q.get(labelsKey).split('|').filter(label => !!label) + const labelsValues = getValue(labelsKey).split('|').filter(label => !!label) const newLabels = Object.fromEntries(clauses.map((clause, index) => [clause, labelsValues[index]])) labels = Object.assign(labels, newLabels) - q.delete(labelsKey) } + } else { + changedSearchRecordEntries.push([key, value]) } } - if (q.get('props')) { - filters.push(...parseLegacyPropsFilter(q.get('props'))) - q.delete('props') + if (getValue('props')) { + filters.push(...parseLegacyPropsFilter(getValue('props'))) } if (filters.length > 0) { - q.set('filters', filters) - q.set('labels', labels) - - history.pushState({}, null, `${window.location.pathname}?${q.toString()}`) + changedSearchRecordEntries.push([['filters', filters], ['labels', labels]]) + history.pushState({}, null, `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`) } } @@ -159,43 +151,46 @@ export function revenueAvailable(query, site) { return revenueGoalsInFilter.length > 0 && singleCurrency } -function QueryLink(props) { +export function QueryLink({ to, search, className, children, onClick }) { + const navigate = useNavigate(); const { query } = useQueryContext(); - const { history, to, className, children } = props - function onClick(e) { + const handleClick = useCallback((e) => { e.preventDefault() - navigateToQuery(history, query, to) - if (props.onClick) { - props.onClick(e) + navigateToQuery(navigate, query, search) + if (onClick) { + onClick(e) } - } + }, [navigate, onClick, query, search]) return ( ({...currentSearch, ...search})} className={className} - onClick={onClick} + onClick={handleClick} > {children} ) } -const QueryLinkWithRouter = withRouter(QueryLink) -export { QueryLinkWithRouter as QueryLink }; - -function QueryButton({ history, to, disabled, className, children, onClick }) { +export function QueryButton({ search, disabled, className, children, onClick }) { + const navigate = useNavigate(); const { query } = useQueryContext(); + + const handleClick = useCallback((e) => { + e.preventDefault() + navigateToQuery(navigate, query, search) + if (onClick) { + onClick(e) + } + }, [navigate, onClick, query, search]) + return (
diff --git a/assets/js/dashboard/stats/graph/with-imported-switch.js b/assets/js/dashboard/stats/graph/with-imported-switch.js index 4f480536b..adf7c479c 100644 --- a/assets/js/dashboard/stats/graph/with-imported-switch.js +++ b/assets/js/dashboard/stats/graph/with-imported-switch.js @@ -1,38 +1,23 @@ import React from "react" -import { Link } from 'react-router-dom' -import * as url from '../../util/url' import { BarsArrowUpIcon } from '@heroicons/react/20/solid' import classNames from "classnames" import { useQueryContext } from "../../query-context" +import { Link } from "@tanstack/react-router" -function LinkOrDiv({ isLink, target, children }) { - if (isLink) { - return {children} - } else { - return
{children}
- } -} - -export default function WithImportedSwitch({ info }) { +export default function WithImportedSwitch({ tooltipMessage, disabled }) { const { query } = useQueryContext(); - if (info && info.visible) { - const { togglable, tooltip_msg } = info - const enabled = togglable && query.with_imported - const target = url.setQuery('with_imported', (!enabled).toString()) + const importsSwitchedOn = query.with_imported; + + const iconClass = classNames("mt-0.5", { + "dark:text-gray-300 text-gray-700": importsSwitchedOn, + "dark:text-gray-500 text-gray-400": !importsSwitchedOn, + }) - const iconClass = classNames("mt-0.5", { - "dark:text-gray-300 text-gray-700": enabled, - "dark:text-gray-500 text-gray-400": !enabled, - }) - - return ( -
- - - -
- ) - } else { - return null - } + return ( +
+ ({...search, with_imported: !importsSwitchedOn})}> + + +
+ ) } \ No newline at end of file diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 9b748eace..7f2c0c046 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -4,12 +4,13 @@ import * as storage from '../../util/storage'; import CountriesMap from './map'; import * as api from '../../api'; -import { apiPath, sitePath } from '../../util/url'; +import { apiPath } from '../../util/url'; import ListReport from '../reports/list'; import * as metrics from '../reports/metrics'; import { hasGoalFilter } from "../../util/filters"; import { getFiltersByKeyPrefix } from '../../util/filters'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; +import { citiesRoute, countriesRoute, regionsRoute } from '../../router'; function Countries({ query, site, onClick, afterFetchData }) { function fetchData() { @@ -43,7 +44,7 @@ function Countries({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Country" metrics={chooseMetrics()} - detailsLink={sitePath('countries')} + detailsLinkProps={{to: countriesRoute.to, search: (search) => search}} renderIcon={renderIcon} color="bg-orange-50" /> @@ -82,7 +83,7 @@ function Regions({ query, site, onClick, afterFetchData }) { onClick={onClick} keyLabel="Region" metrics={chooseMetrics()} - detailsLink={sitePath('regions')} + detailsLinkProps={{to: regionsRoute.to, search: (search) => search}} renderIcon={renderIcon} color="bg-orange-50" /> @@ -120,7 +121,7 @@ function Cities({ query, site, afterFetchData }) { getFilterFor={getFilterFor} keyLabel="City" metrics={chooseMetrics()} - detailsLink={sitePath('cities')} + detailsLinkProps={{to: citiesRoute.to, search: (search) => search}} renderIcon={renderIcon} color="bg-orange-50" /> @@ -235,7 +236,7 @@ export default class Locations extends React.Component {

{labelFor[this.state.mode] || 'Locations'}

- +
{this.renderPill('Map', 'map')} diff --git a/assets/js/dashboard/stats/locations/map.js b/assets/js/dashboard/stats/locations/map.js index ddc40080f..17447b487 100644 --- a/assets/js/dashboard/stats/locations/map.js +++ b/assets/js/dashboard/stats/locations/map.js @@ -1,6 +1,5 @@ import React from 'react'; import Datamap from 'datamaps' -import { withRouter } from 'react-router-dom' import * as d3 from "d3" import numberFormatter from '../../util/number-formatter' @@ -10,6 +9,7 @@ import MoreLink from '../more-link' import * as api from '../../api' import { navigateToQuery } from '../../query' import { cleanLabels, replaceFilterByPrefix } from '../../util/filters'; +import { countriesRoute } from '../../router'; class Countries extends React.Component { constructor(props) { @@ -153,7 +153,7 @@ class Countries extends React.Component { return ( <>
- + search}} /> {this.geolocationDbNotice()} ) @@ -174,4 +174,4 @@ class Countries extends React.Component { } } -export default withRouter(Countries) +export default function CountriesWithRouter(props) {return ()} diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index d97b50c3e..abf523dee 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -3,9 +3,9 @@ import React, { useState, useEffect, useRef } from "react"; import { trimURL } from '../../util/url' import { FilterLink } from "../reports/list"; import { useQueryContext } from "../../query-context"; -import { useSiteContext } from "../../site-context"; import { useDebounce } from "../../custom-hooks"; import { useAPIClient } from "../../hooks/api-client"; +import { rootRoute } from "../../router"; export const MIN_HEIGHT_PX = 500 @@ -86,7 +86,6 @@ export default function BreakdownModal({ }) { const searchBoxRef = useRef(null) const { query } = useQueryContext(); - const site = useSiteContext(); const [search, setSearch] = useState('') @@ -160,7 +159,7 @@ export default function BreakdownModal({ {maybeRenderIcon(item)} {trimURL(item.name, 40)} diff --git a/assets/js/dashboard/stats/modals/filter-modal.js b/assets/js/dashboard/stats/modals/filter-modal.js index a1984a33c..9c840ae42 100644 --- a/assets/js/dashboard/stats/modals/filter-modal.js +++ b/assets/js/dashboard/stats/modals/filter-modal.js @@ -1,13 +1,13 @@ -import React from "react"; -import { withRouter } from 'react-router-dom'; +import React from 'react' +import { useNavigate } from '@tanstack/react-router'; import Modal from './modal'; import { EVENT_PROPS_PREFIX, FILTER_GROUP_TO_MODAL_TYPE, formatFilterGroup, FILTER_OPERATIONS, getFilterGroup, FILTER_MODAL_TO_FILTER_GROUP } from '../../util/filters'; -import { parseQuery } from '../../query'; -import { updatedQuery } from '../../util/url'; +import { useQueryContext } from '../../query-context'; import { shouldIgnoreKeypress } from '../../keybinding'; import { cleanLabels } from "../../util/filters"; import FilterModalGroup from "./filter-modal-group"; +import { filterRoute, rootRoute } from '../../router'; function partitionFilters(modalType, filters) { const otherFilters = [] @@ -44,21 +44,21 @@ class FilterModal extends React.Component { constructor(props) { super(props) - const modalType = props.match.params.field || 'page' + const modalType = this.props.modalType - const query = parseQuery(props.location.search, props.site) + const query = this.props.query const { filterState, otherFilters, hasRelevantFilters } = partitionFilters(modalType, query.filters) this.handleKeydown = this.handleKeydown.bind(this) - this.state = { query, modalType, filterState, labelState: query.labels, otherFilters, hasRelevantFilters } + this.state = { query, filterState, labelState: query.labels, otherFilters, hasRelevantFilters } } componentDidMount() { - document.addEventListener("keydown", this.handleKeydown) + document.addEventListener('keydown', this.handleKeydown) } componentWillUnmount() { - document.removeEventListener("keydown", this.handleKeydown); + document.removeEventListener('keydown', this.handleKeydown) } handleKeydown(e) { @@ -82,12 +82,13 @@ class FilterModal extends React.Component { } selectFiltersAndCloseModal(filters) { - this.props.history.replace({ - pathname: '/', - search: updatedQuery({ + this.props.navigate({ + to: rootRoute.to, + search: { filters: filters, labels: cleanLabels(filters, this.state.labelState) - }) + }, + replace: true }) } @@ -110,7 +111,7 @@ class FilterModal extends React.Component { } onAddRow(filterGroup) { - this.setState(prevState => { + this.setState((prevState) => { const filter = emptyFilter(filterGroup) const id = `${filterGroup}${Object.keys(this.state.filterState).length}` @@ -134,12 +135,14 @@ class FilterModal extends React.Component { render() { return ( -

Filter by {formatFilterGroup(this.state.modalType)}

+

+ Filter by {formatFilterGroup(this.props.modalType)} +

- {FILTER_MODAL_TO_FILTER_GROUP[this.state.modalType].map((filterGroup) => ( + {FILTER_MODAL_TO_FILTER_GROUP[this.props.modalType].map((filterGroup) => ( - Remove filter{FILTER_MODAL_TO_FILTER_GROUP[this.state.modalType].length > 1 ? 's' : ''} + Remove filter{FILTER_MODAL_TO_FILTER_GROUP[this.props.modalType].length > 1 ? 's' : ''} )}
@@ -180,4 +183,16 @@ class FilterModal extends React.Component { } } -export default withRouter(FilterModal) +export default function FilterModalWithRouter(props) { + const navigate = useNavigate() + const { field } = filterRoute.useParams() + const { query } = useQueryContext() + return ( + + ) +} diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index 7e7719b88..8431d9e3d 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -1,11 +1,10 @@ import React, { useCallback } from "react"; -import { withRouter } from 'react-router-dom' -import Modal from './modal' +import Modal from "./modal"; import { hasGoalFilter } from "../../util/filters"; import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; -import * as url from '../../util/url'; +import * as url from "../../util/url"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; @@ -15,13 +14,10 @@ const VIEWS = { cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City' }, } -function LocationsModal({ location }) { +function LocationsModal({ currentView }) { const { query } = useQueryContext(); const site = useSiteContext(); - const urlParts = location.pathname.split('/') - const currentView = urlParts[urlParts.length - 1] - let reportInfo = VIEWS[currentView] reportInfo = {...reportInfo, endpoint: url.apiPath(site, reportInfo.endpoint)} @@ -73,4 +69,4 @@ function LocationsModal({ location }) { ) } -export default withRouter(LocationsModal) +export default LocationsModal diff --git a/assets/js/dashboard/stats/modals/modal.js b/assets/js/dashboard/stats/modals/modal.js index a6cbbabde..66faa80a4 100644 --- a/assets/js/dashboard/stats/modals/modal.js +++ b/assets/js/dashboard/stats/modals/modal.js @@ -1,7 +1,8 @@ import React from "react"; import { createPortal } from "react-dom"; -import { withRouter } from 'react-router-dom'; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { shouldIgnoreKeypress } from '../../keybinding' +import { rootRoute } from "../../router"; // This corresponds to the 'md' breakpoint on TailwindCSS. const MD_WIDTH = 768; @@ -57,7 +58,10 @@ class Modal extends React.Component { } close() { - this.props.history.push(`/${this.props.location.search}`) + this.props.navigate({ + to: rootRoute.to, + search: (search) => search, + }) } /** @@ -91,7 +95,6 @@ class Modal extends React.Component { > {this.props.children} - , document.getElementById("modal_root"), @@ -99,5 +102,8 @@ class Modal extends React.Component { } } - -export default withRouter(Modal) +export default function ModalWithRouting(props) { + const navigate = useNavigate() + const location = useLocation() + return +} diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index cafa9e200..d71a0b027 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -1,5 +1,4 @@ import React, { useCallback } from "react"; -import { withRouter } from 'react-router-dom' import Modal from './modal' import { addFilter } from '../../query' @@ -11,11 +10,12 @@ import * as url from "../../util/url"; import { revenueAvailable } from "../../query"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { customPropsRoute } from "../../router"; -function PropsModal({ location }) { +function PropsModal() { const { query } = useQueryContext(); const site = useSiteContext(); - const propKey = location.pathname.split('/').filter(i => i).pop() + const { propKey } = customPropsRoute.useParams() /*global BUILD_EXTRA*/ const showRevenueMetrics = BUILD_EXTRA && revenueAvailable(query, site) @@ -61,4 +61,4 @@ function PropsModal({ location }) { ) } -export default withRouter(PropsModal) +export default PropsModal \ No newline at end of file diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 9bfb52fd9..4467678ff 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -1,5 +1,4 @@ -import React, { useCallback } from "react"; -import { withRouter } from 'react-router-dom' +import React, { useCallback } from 'react' import Modal from './modal' import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; @@ -9,15 +8,17 @@ import * as url from "../../util/url"; import { addFilter } from "../../query"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { referrersDrilldownRoute } from '../../router'; -function ReferrerDrilldownModal({ match }) { +function ReferrerDrilldownModal() { + const { referrer } = referrersDrilldownRoute.useParams(); const { query } = useQueryContext(); const site = useSiteContext(); const reportInfo = { title: "Referrer Drilldown", dimension: 'referrer', - endpoint: url.apiPath(site, `/referrers/${match.params.referrer}`), + endpoint: url.apiPath(site, `/referrers/${referrer}`), dimensionLabel: "Referrer" } @@ -83,4 +84,4 @@ function ReferrerDrilldownModal({ match }) { ) } -export default withRouter(ReferrerDrilldownModal) +export default ReferrerDrilldownModal diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 1d8b45afd..cc8c5ba4c 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -1,6 +1,4 @@ import React, { useCallback } from "react"; -import { withRouter } from 'react-router-dom' - import Modal from './modal' import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; import BreakdownModal from "./breakdown-modal"; @@ -39,13 +37,10 @@ const VIEWS = { }, } -function SourcesModal({ location }) { +function SourcesModal({ currentView }) { const { query } = useQueryContext(); const site = useSiteContext(); - const urlParts = location.pathname.split('/') - const currentView = urlParts[urlParts.length - 1] - let reportInfo = VIEWS[currentView].info reportInfo = {...reportInfo, endpoint: url.apiPath(site, reportInfo.endpoint)} @@ -95,4 +90,4 @@ function SourcesModal({ location }) { ) } -export default withRouter(SourcesModal) +export default SourcesModal diff --git a/assets/js/dashboard/stats/more-link.js b/assets/js/dashboard/stats/more-link.js index 3940cbbc5..6b7ef7de5 100644 --- a/assets/js/dashboard/stats/more-link.js +++ b/assets/js/dashboard/stats/more-link.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom' +import { Link } from '@tanstack/react-router' function detailsIcon() { return ( @@ -20,12 +20,12 @@ function detailsIcon() { ) } -export default function MoreLink({ url, list, endpoint, className, onClick }) { +export default function MoreLink({ linkProps, list, className, onClick }) { if (list.length > 0) { return (
search}} externalLinkDest={externalLinkDest} color="bg-orange-50" /> @@ -81,7 +82,7 @@ function ExitPages({ afterFetchData }) { getFilterFor={getFilterFor} keyLabel="Exit page" metrics={chooseMetrics()} - detailsLink={url.sitePath('exit-pages')} + detailsLinkProps={{to: exitPagesRoute.to, search: (search) => search}} externalLinkDest={externalLinkDest} color="bg-orange-50" /> @@ -120,7 +121,7 @@ function TopPages({ afterFetchData }) { getFilterFor={getFilterFor} keyLabel="Page" metrics={chooseMetrics()} - detailsLink={url.sitePath('pages')} + detailsLinkProps={{to: topPagesRoute.to, search: (search) => search}} externalLinkDest={externalLinkDest} color="bg-orange-50" /> diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index e607b1d43..323457dec 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from '@tanstack/react-router'; import FlipMove from 'react-flip-move'; import FadeIn from '../../fade-in'; @@ -7,7 +7,7 @@ import MoreLink from '../more-link'; import Bar from '../bar'; import LazyLoader from '../../components/lazy-loader'; import classNames from 'classnames'; -import { trimURL, updatedQuery } from '../../util/url'; +import { trimURL } from '../../util/url'; import { cleanLabels, replaceFilterByPrefix, isRealTimeDashboard, hasGoalFilter } from '../../util/filters'; import { useQueryContext } from '../../query-context'; @@ -18,7 +18,7 @@ const ROW_GAP_HEIGHT = 4 const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT const COL_MIN_WIDTH = 70 -export function FilterLink({ pathname, filterInfo, onClick, children, extraClass }) { +export function FilterLink({ to, filterInfo, onClick, children, extraClass }) { const { query } = useQueryContext(); const className = classNames(`${extraClass}`, { 'hover:underline': !!filterInfo }) @@ -26,13 +26,9 @@ export function FilterLink({ pathname, filterInfo, onClick, children, extraClass const { prefix, filter, labels } = filterInfo const newFilters = replaceFilterByPrefix(query, prefix, filter) const newLabels = cleanLabels(newFilters, query.labels, filter[1], labels) - const filterQuery = updatedQuery({ filters: newFilters, labels: newLabels }) - - let linkTo = { search: filterQuery.toString() } - if (pathname) { linkTo.pathname = pathname } return ( - + {return {...search, filters: newFilters, labels: newLabels}}} > {children} ) @@ -81,9 +77,7 @@ function ExternalLink({ item, externalLinkDest }) { * OPTIONAL PROPS * * @param {Function} [onClick] - Function with additional action to be taken when a list entry is clicked. - * @param {string} [detailsLink] - The pathname to the detailed view of this report. E.g.: - * `/dummy.site/pages`. If this is given as input to the ListReport, the Details button - * will always be rendered. + * @param {Object} [detailsLinkProps] - Navigation props to be passed to "More" link, if any. * @param {boolean} [maybeHideDetails] - Set this to `true` if the details button should be hidden on * the condition that there are less than MAX_ITEMS entries in the list (i.e. nothing * more to show). @@ -105,7 +99,7 @@ function ExternalLink({ item, externalLinkDest }) { * | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... * | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... */ -export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WIDTH, afterFetchData, detailsLink, maybeHideDetails, onClick, color, getFilterFor, renderIcon, externalLinkDest, fetchData }) { +export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WIDTH, afterFetchData, detailsLinkProps, maybeHideDetails, onClick, color, getFilterFor, renderIcon, externalLinkDest, fetchData }) { const { query } = useQueryContext(); const [state, setState] = useState({ loading: true, list: null }) const [visible, setVisible] = useState(false) @@ -296,8 +290,8 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI const moreResultsAvailable = state.list.length >= MAX_ITEMS const hideDetails = maybeHideDetails && !moreResultsAvailable - const showDetails = detailsLink && !state.loading && !hideDetails - return showDetails && + const showDetails = !!detailsLinkProps && !state.loading && !hideDetails + return showDetails && } return ( diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index caa7d2cdb..2759dbf90 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -7,6 +7,7 @@ import ListReport from '../reports/list'; import ImportedQueryUnsupportedWarning from '../../stats/imported-query-unsupported-warning'; import { useQueryContext } from '../../query-context'; import { useSiteContext } from '../../site-context'; +import { referrersDrilldownRoute } from '../../router'; export default function Referrers({ source }) { const { query } = useQueryContext(); @@ -68,7 +69,7 @@ export default function Referrers({ source }) { getFilterFor={getFilterFor} keyLabel="Referrer" metrics={chooseMetrics()} - detailsLink={url.sitePath(`referrers/${encodeURIComponent(source)}`)} + detailsLinkProps={{to: referrersDrilldownRoute.to, params: {referrer: source}, search: (search) => search}} externalLinkDest={externalLinkDest} renderIcon={renderIcon} color="bg-blue-50" diff --git a/assets/js/dashboard/stats/sources/search-terms.js b/assets/js/dashboard/stats/sources/search-terms.js index 2b8660d9f..d52ffa4e1 100644 --- a/assets/js/dashboard/stats/sources/search-terms.js +++ b/assets/js/dashboard/stats/sources/search-terms.js @@ -6,6 +6,7 @@ import numberFormatter from '../../util/number-formatter' import RocketIcon from '../modals/rocket-icon' import * as api from '../../api' import LazyLoader from '../../components/lazy-loader' +import { referrersGoogleRoute } from '../../router'; export function ConfigureSearchTermsCTA({site}) { return ( @@ -112,7 +113,7 @@ export default class SearchTerms extends React.Component {

Search Terms

{this.renderList()} - + search}} className="w-full pb-4 absolute bottom-0 left-0" />
) } diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index e04b1fb16..cb6363747 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -12,6 +12,7 @@ import classNames from 'classnames'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; import { useQueryContext } from '../../query-context'; import { useSiteContext } from '../../site-context'; +import { sourcesRoute, utmCampaignsRoute, utmContentsRoute, utmMediumsRoute, utmSourcesRoute, utmTermsRoute } from '../../router'; const UTM_TAGS = { utm_medium: { label: 'UTM Medium', shortLabel: 'UTM Medium', endpoint: '/utm_mediums' }, @@ -24,7 +25,6 @@ const UTM_TAGS = { function AllSources({ afterFetchData }) { const { query } = useQueryContext(); const site = useSiteContext(); - function fetchData() { return api.get(url.apiPath(site, '/sources'), query, { limit: 9 }) } @@ -51,7 +51,7 @@ function AllSources({ afterFetchData }) { hasGoalFilter(query) && metrics.createConversionRate(), ].filter(metric => !!metric) } - + return ( search}} renderIcon={renderIcon} color="bg-blue-50" /> @@ -71,6 +71,14 @@ function UTMSources({ tab, afterFetchData }) { const site = useSiteContext(); const utmTag = UTM_TAGS[tab] + const route = { + utm_medium: utmMediumsRoute, + utm_source: utmSourcesRoute, + utm_campaign: utmCampaignsRoute, + utm_content: utmContentsRoute, + utm_term: utmTermsRoute, + }[tab] + function fetchData() { return api.get(url.apiPath(site, utmTag.endpoint), query, { limit: 9 }) } @@ -89,6 +97,7 @@ function UTMSources({ tab, afterFetchData }) { ].filter(metric => !!metric) } + return ( search}} color="bg-blue-50" /> ) diff --git a/assets/js/dashboard/util/storage.js b/assets/js/dashboard/util/storage.js index 3333199e4..642d2db33 100644 --- a/assets/js/dashboard/util/storage.js +++ b/assets/js/dashboard/util/storage.js @@ -33,3 +33,5 @@ export function getItem(key) { return memStore[key] } } + +export const getDomainScopedStorageKey = (key, domain) => `${key}__${domain}` diff --git a/assets/js/dashboard/util/url.js b/assets/js/dashboard/util/url.js index f201c6fe0..ab8ac00f6 100644 --- a/assets/js/dashboard/util/url.js +++ b/assets/js/dashboard/util/url.js @@ -1,24 +1,10 @@ import JsonURL from '@jsonurl/jsonurl' +import { parseSearchWith } from '@tanstack/react-router'; export function apiPath(site, path = '') { return `/api/stats/${encodeURIComponent(site.domain)}${path}/` } -export function sitePath(path = '') { - return (path.startsWith('/') ? path : '/' + path) + window.location.search -} - -export function setQuery(key, value) { - return `${window.location.pathname}?${updatedQuery({ [key]: value })}` -} - -export function updatedQuery(values) { - const queryString = new PlausibleSearchParams(window.location.search) - Object.entries(values).forEach(([key, value]) => queryString.set(key, value)) - - return queryString.toString() -} - export function externalLinkForPage(domain, page) { const domainURL = new URL(`https://${domain}`) return `https://${domainURL.host}${page}` @@ -80,37 +66,48 @@ export function trimURL(url, maxLength) { } } -export class PlausibleSearchParams extends URLSearchParams { - set(key, value) { - if (typeof value === 'object') { - value = JsonURL.stringify(value) - if (value.length > 2) { - super.set(key, value) - } else { - // Empty arrays/objects are handled by defaults - super.delete(key) - } - } else if (value === false) { - super.delete(key) - } else { - super.set(key, value) - } - } - - escape(value) { - // Less strict encoding - allow components which browsers don't require encoded and make jsonurl - // more readable - return encodeURIComponent(value) - .replaceAll("%2C", ",") - .replaceAll("%3A", ":") - .replaceAll("%2F", "/") - } - - toString() { - const entries = Array.from(super.entries()) - if (entries.length === 0) { - return '' - } - return entries.map(([key, value]) => `${this.escape(key)}=${this.escape(value)}`).join("&") - } +/** + * @param {String} input - value to encode for URI + * @returns {String} value encoded for URI + */ +export function encodeURIComponentPermissive(input) { + return encodeURIComponent(input) + .replaceAll("%2C", ",") + .replaceAll("%3A", ":") + .replaceAll("%2F", "/") } + +export function encodeSearchParamEntries([k, v]) { + return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}` +} + +export function isSearchEntryDefined([_key, value]) { + return value !== undefined +} + +export function stringifySearch(searchRecord) { + const definedSearchEntries = Object.entries(searchRecord || {}).map(stringifySearchEntry).filter(isSearchEntryDefined) + + const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntries) + + return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : '' +} + +export function stringifySearchEntry([key, value]) { + 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) { + const fragmentWithEncodedEquals = searchStringFragment.replaceAll('=','%3D'); + return JsonURL.parse(fragmentWithEncodedEquals) +} + +export const parseSearch = parseSearchWith(parseSearchFragment) \ No newline at end of file diff --git a/assets/package-lock.json b/assets/package-lock.json index 70f8dea1f..7bc723cc3 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -18,6 +18,7 @@ "@tailwindcss/forms": "^0.5.6", "@tailwindcss/typography": "^0.4.1", "@tanstack/react-query": "^5.51.1", + "@tanstack/react-router": "^1.45.7", "abortcontroller-polyfill": "^1.7.3", "alpinejs": "^3.13.1", "chart.js": "^3.3.2", @@ -35,7 +36,6 @@ "react-flip-move": "^3.0.4", "react-intersection-observer": "^9.5.2", "react-popper": "^2.3.0", - "react-router-dom": "^5.2.0", "react-transition-group": "^4.4.2", "url-search-params-polyfill": "^8.1.1" }, @@ -447,6 +447,18 @@ "tailwindcss": ">=2.0.0" } }, + "node_modules/@tanstack/history": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.45.3.tgz", + "integrity": "sha512-n4XXInV9irIq0obRvINIkESkGk280Q+xkIIbswmM0z9nAu2wsIRZNvlmPrtYh6bgNWtItOWWoihFUjLTW8g6Jg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.51.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.1.tgz", @@ -471,6 +483,54 @@ "react": "^18.0.0" } }, + "node_modules/@tanstack/react-router": { + "version": "1.45.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.45.7.tgz", + "integrity": "sha512-5PmjvK6uqSKdLQh3oI0qeuFblu8a+cRTsXCou/o2ykqFaU7nxHQxw505PFypkKA47Qumtc9tDwzqbCHXGd6tKg==", + "dependencies": { + "@tanstack/history": "1.45.3", + "@tanstack/react-store": "^0.5.4", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.5.5.tgz", + "integrity": "sha512-1orYXGatBqXCYKuroFwV8Ll/6aDa5E3pU6RR4h7RvRk7TmxF1+zLCsWALZaeijXkySNMGmvawSbUXRypivg2XA==", + "dependencies": { + "@tanstack/store": "0.5.5", + "use-sync-external-store": "^1.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.5.5.tgz", + "integrity": "sha512-EOSrgdDAJExbvRZEQ/Xhh9iZchXpMN+ga1Bnk8Nmygzs8TfiE6hbzThF+Pr2G19uHL6+DTDTHhJ8VQiOd7l4tA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/d3": { "version": "3.5.38", "license": "MIT" @@ -2394,25 +2454,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/history": { - "version": "4.10.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, "node_modules/hosted-git-info": { "version": "4.1.0", "dev": true, @@ -3478,17 +3519,6 @@ "version": "1.0.7", "license": "MIT" }, - "node_modules/path-to-regexp": { - "version": "1.8.0", - "license": "MIT", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-to-regexp/node_modules/isarray": { - "version": "0.0.1", - "license": "MIT" - }, "node_modules/path-type": { "version": "4.0.0", "dev": true, @@ -3862,40 +3892,6 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, - "node_modules/react-router": { - "version": "5.3.4", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/react-router-dom": { - "version": "5.3.4", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -4090,10 +4086,6 @@ "node": ">=4" } }, - "node_modules/resolve-pathname": { - "version": "3.0.0", - "license": "MIT" - }, "node_modules/reusify": { "version": "1.0.4", "license": "MIT", @@ -4873,8 +4865,9 @@ } }, "node_modules/tiny-invariant": { - "version": "1.3.1", - "license": "MIT" + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/tiny-warning": { "version": "1.0.3", @@ -5026,6 +5019,14 @@ "version": "8.1.1", "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" @@ -5044,10 +5045,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/value-equal": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/vlq": { "version": "0.2.3", "license": "MIT" diff --git a/assets/package.json b/assets/package.json index b770e4592..eaafd818e 100644 --- a/assets/package.json +++ b/assets/package.json @@ -18,6 +18,7 @@ "@tailwindcss/forms": "^0.5.6", "@tailwindcss/typography": "^0.4.1", "@tanstack/react-query": "^5.51.1", + "@tanstack/react-router": "^1.45.7", "abortcontroller-polyfill": "^1.7.3", "alpinejs": "^3.13.1", "chart.js": "^3.3.2", @@ -35,7 +36,6 @@ "react-flip-move": "^3.0.4", "react-intersection-observer": "^9.5.2", "react-popper": "^2.3.0", - "react-router-dom": "^5.2.0", "react-transition-group": "^4.4.2", "url-search-params-polyfill": "^8.1.1" },