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
This commit is contained in:
Artur Pata 2024-07-24 15:14:00 +03:00 committed by GitHub
parent 6ed4f3ad69
commit 28cf3ff2b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 612 additions and 480 deletions

View File

@ -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 = (
<ErrorBoundary>
<SiteContextProvider site={site}>
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
<Router />
<RouterProvider router={router} />
</UserContextProvider>
</SiteContextProvider>
</ErrorBoundary>

View File

@ -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" && <span>
<hr className="my-1" />
<MatchDayOfWeekInput history={history} />
<MatchDayOfWeekInput />
</span>}
</Menu.Items>
</Transition>
@ -202,4 +219,4 @@ const ComparisonInput = function ({ history }) {
)
}
export default withRouter(ComparisonInput)
export default ComparisonInput

View File

@ -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 (
<div className={containerClass}>
<QueryButton
to={{ date: prevDate }}
search={{ date: prevDate }}
className={leftClass}
disabled={disabledLeft}
>
@ -88,7 +88,7 @@ function renderArrow(query, site, period, prevDate, nextDate) {
</svg>
</QueryButton>
<QueryButton
to={{ date: nextDate }}
search={{ date: nextDate }}
className={rightClass}
disabled={disabledRight}
>
@ -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 (
<QueryLink
to={{ from: false, to: false, period, ...opts }}
search={{ from: null, to: null, period, ...opts }}
onClick={() => 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 }) {
<div className="py-1 date-option-group border-t border-gray-200 dark:border-gray-500">
<span
onClick={() => {
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

View File

@ -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 (
<Menu.Item key={filterIndex}>
<div className="px-3 md:px-4 sm:py-2 py-3 text-sm leading-tight flex items-center justify-between" key={filterIndex}>
<Link
title={`Edit filter: ${formattedFilters[type]}`}
to={{ pathname: `/${encodeURIComponent(site.domain)}/filter/${FILTER_GROUP_TO_MODAL_TYPE[type]}`, search: window.location.search }}
to={'filter/$field'}
params={{field: FILTER_GROUP_TO_MODAL_TYPE[type]}}
search={(search) => 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) {
<b
title={`Remove filter: ${formattedFilters[type]}`}
className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500"
onClick={() => removeFilter(filterIndex, history, query)}
onClick={() => removeFilter(filterIndex, navigate, query)}
>
<XMarkIcon className="w-4 h-4" />
</b>
@ -81,12 +85,14 @@ function renderDropdownFilter(filterIndex, filter, site, history, query) {
)
}
function filterDropdownOption(site, option) {
function filterDropdownOption(option) {
return (
<Menu.Item key={option}>
{({ active }) => (
<Link
to={{ pathname: `/filter/${option}`, search: window.location.search }}
to={filterRoute.to}
params={{field: option}}
search={(search) => 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 }) {
<div className="border-b border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => setAddingFilter(true)}>
+ Add filter
</div>
{query.filters.map((filter, index) => renderDropdownFilter(index, filter, site, history, query))}
{query.filters.map((filter, index) => renderDropdownFilter(index, filter, navigate, query))}
<Menu.Item key="clear">
<div className="border-t border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => clearAllFilters(history, query)}>
<div className="border-t border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => clearAllFilters(navigate, query)}>
Clear All Filters
</div>
</Menu.Item>
@ -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 }) {
<Link
title={`Edit filter: ${formattedFilters[type]}`}
className="flex w-full h-full items-center py-2 pl-3"
to={{
pathname: `/filter/${FILTER_GROUP_TO_MODAL_TYPE[type]}`,
search: window.location.search
}}
to={filterRoute.to}
params={{field: FILTER_GROUP_TO_MODAL_TYPE[type]}}
search={(search)=> search}
>
<span className="inline-block max-w-2xs md:max-w-xs truncate">{text}</span>
</Link>
<span
title={`Remove filter: ${formattedFilters[type]}`}
className="flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center"
onClick={() => removeFilter(filterIndex, history, query)}
onClick={() => removeFilter(filterIndex, navigate, query)}
>
<XMarkIcon className="w-4 h-4" />
</span>
@ -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"
>
<DropdownContent history={history} wrapped={wrapped} />
<DropdownContent wrapped={wrapped} />
</div>
</Menu.Items>
</Transition>
@ -301,4 +309,4 @@ function Filters({ history }) {
)
}
export default withRouter(Filters);
export default Filters;

View File

@ -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 <QueryContext.Provider value={{ query, lastLoadTimestamp }}>{children}</QueryContext.Provider>
};
export default withRouter(QueryContextProvider);

View File

@ -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 (
<Link
to={{ pathname: window.location.pathname, search: updatedQuery(to) }}
to={to}
search={(currentSearch) => ({...currentSearch, ...search})}
className={className}
onClick={onClick}
onClick={handleClick}
>
{children}
</Link>
)
}
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 (
<button
className={className}
onClick={(event) => {
event.preventDefault()
navigateToQuery(history, query, to)
if (onClick) onClick(event)
history.push({ search: updatedQuery(to) })
}}
onClick={handleClick}
type="button"
disabled={disabled}
>
@ -204,5 +199,3 @@ function QueryButton({ history, to, disabled, className, children, onClick }) {
)
}
const QueryButtonWithRouter = withRouter(QueryButton)
export { QueryButtonWithRouter as QueryButton };

View File

@ -1,5 +1,10 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Switch, Route, useLocation } from "react-router-dom";
import React from 'react'
import {
createRouter,
createRootRoute,
Outlet,
createRoute,
} from '@tanstack/react-router'
import Dashboard from './index'
import SourcesModal from './stats/modals/sources'
@ -8,17 +13,13 @@ import GoogleKeywordsModal from './stats/modals/google-keywords'
import PagesModal from './stats/modals/pages'
import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages'
import LocationsModal from './stats/modals/locations-modal';
import LocationsModal from './stats/modals/locations-modal'
import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
import QueryContextProvider from './query-context';
import { useSiteContext } from './site-context';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import QueryContextProvider from './query-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { parseSearch, stringifySearch } from './util/url'
const queryClient = new QueryClient({
defaultOptions: {
@ -28,62 +29,156 @@ const queryClient = new QueryClient({
}
})
function ScrollToTop() {
const location = useLocation();
useEffect(() => {
if (location.state && location.state.scrollTop) {
window.scrollTo(0, 0);
}
}, [location]);
return null;
}
export default function Router() {
const site = useSiteContext()
function DashboardRoute() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={site.shared ? `/share/${encodeURIComponent(site.domain)}` : encodeURIComponent(site.domain)}>
<QueryContextProvider>
<Route path="/">
<ScrollToTop />
<Dashboard />
<Switch>
<Route exact path={["/sources", "/utm_mediums", "/utm_sources", "/utm_campaigns", "/utm_contents", "/utm_terms"]}>
<SourcesModal />
</Route>
<Route exact path="/referrers/Google">
<GoogleKeywordsModal site={site} />
</Route>
<Route exact path="/referrers/:referrer">
<ReferrersDrilldownModal />
</Route>
<Route path="/pages">
<PagesModal />
</Route>
<Route path="/entry-pages">
<EntryPagesModal />
</Route>
<Route path="/exit-pages">
<ExitPagesModal />
</Route>
<Route exact path={["/countries", "/regions", "/cities"]}>
<LocationsModal />
</Route>
<Route path="/custom-prop-values/:prop_key">
<PropsModal />
</Route>
<Route path="/conversions">
<ConversionsModal />
</Route>
<Route path={["/filter/:field"]}>
<FilterModal site={site} />
</Route>
</Switch>
</Route>
</QueryContextProvider>
</BrowserRouter>
<QueryContextProvider>
<Dashboard />
{/** render any children of the root route below */}
<Outlet />
</QueryContextProvider>
</QueryClientProvider>
);
)
}
export const rootRoute = createRootRoute({
component: DashboardRoute,
// renders null in the <Outlet /> for unhandleable routes like /${site.domain}/does/not/exist
notFoundComponent: () => null
})
export const sourcesRoute = createRoute({
path: 'sources',
component: () => <SourcesModal currentView="sources" />,
getParentRoute: () => rootRoute
})
export const utmMediumsRoute = createRoute({
path: 'utm_mediums',
component: () => <SourcesModal currentView="utm_mediums" />,
getParentRoute: () => rootRoute
})
export const utmSourcesRoute = createRoute({
path: 'utm_sources',
component: () => <SourcesModal currentView="utm_sources" />,
getParentRoute: () => rootRoute
})
export const utmCampaignsRoute = createRoute({
path: 'utm_campaigns',
component: () => <SourcesModal currentView="utm_campaigns" />,
getParentRoute: () => rootRoute
})
export const utmContentsRoute = createRoute({
path: 'utm_contents',
component: () => <SourcesModal currentView="utm_contents" />,
getParentRoute: () => rootRoute
})
export const utmTermsRoute = createRoute({
path: 'utm_terms',
component: () => <SourcesModal currentView="utm_terms" />,
getParentRoute: () => rootRoute
})
export const referrersGoogleRoute = createRoute({
path: 'referrers/Google',
component: GoogleKeywordsModal,
getParentRoute: () => rootRoute
})
export const topPagesRoute = createRoute({
path: 'pages',
component: PagesModal,
getParentRoute: () => rootRoute
})
export const entryPagesRoute = createRoute({
path: 'entry-pages',
component: EntryPagesModal,
getParentRoute: () => rootRoute
})
export const exitPagesRoute = createRoute({
path: 'exit-pages',
component: ExitPagesModal,
getParentRoute: () => rootRoute
})
export const countriesRoute = createRoute({
path: 'countries',
component: () => <LocationsModal currentView="countries" />,
getParentRoute: () => rootRoute
})
export const regionsRoute = createRoute({
path: 'regions',
component: () => <LocationsModal currentView="regions" />,
getParentRoute: () => rootRoute
})
export const citiesRoute = createRoute({
path: 'cities',
component: () => <LocationsModal currentView="cities" />,
getParentRoute: () => rootRoute
})
export const conversionsRoute = createRoute({
path: 'conversions',
component: ConversionsModal,
getParentRoute: () => rootRoute
})
export const referrersDrilldownRoute = createRoute({
path: 'referrers/$referrer',
component: ReferrersDrilldownModal,
getParentRoute: () => rootRoute
})
export const customPropsRoute = createRoute({
path: 'custom-prop-values/$propKey',
component: PropsModal,
getParentRoute: () => rootRoute
})
export const filterRoute = createRoute({
path: 'filter/$field',
component: FilterModal,
getParentRoute: () => rootRoute
})
const routeTree = rootRoute.addChildren([
sourcesRoute,
utmMediumsRoute,
utmSourcesRoute,
utmCampaignsRoute,
utmContentsRoute,
utmTermsRoute,
referrersGoogleRoute,
referrersDrilldownRoute,
topPagesRoute,
entryPagesRoute,
exitPagesRoute,
countriesRoute,
regionsRoute,
citiesRoute,
conversionsRoute,
customPropsRoute,
filterRoute
])
export function createAppRouter(site) {
const basepath = site.shared
? `/share/${encodeURIComponent(site.domain)}`
: encodeURIComponent(site.domain)
return createRouter({
routeTree,
stringifySearch: stringifySearch,
parseSearch: parseSearch,
basepath
})
}

View File

@ -103,6 +103,7 @@ export default class SiteSwitcher extends React.Component {
siteNum <= sites.length &&
sites[siteNum - 1] !== site.domain
) {
// has to change window.location because Router is rendered with /${site.domain} as the basepath
window.location = `/${encodeURIComponent(sites[siteNum - 1])}`
}
}

View File

@ -6,6 +6,7 @@ import * as metrics from '../reports/metrics';
import ListReport from '../reports/list';
import { useSiteContext } from '../../site-context';
import { useQueryContext } from '../../query-context';
import { conversionsRoute } from '../../router';
export default function Conversions({ afterFetchData, onGoalFilterClick }) {
const site = useSiteContext();
@ -41,7 +42,7 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
keyLabel="Goal"
onClick={onGoalFilterClick}
metrics={chooseMetrics()}
detailsLink={url.sitePath('conversions')}
detailsLinkProps={{to: conversionsRoute.to, search:(search) => search}}
maybeHideDetails={true}
color="bg-red-50"
colMinWidth={90}

View File

@ -7,6 +7,7 @@ import * as api from "../../api"
import { EVENT_PROPS_PREFIX, getGoalFilter } from "../../util/filters"
import { useSiteContext } from "../../site-context"
import { useQueryContext } from "../../query-context"
import { customPropsRoute } from "../../router"
export const SPECIAL_GOALS = {
'404': { title: '404 Pages', prop: 'path' },
@ -71,7 +72,7 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel={prop}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${prop}`)}
detailsLinkProps={{to: customPropsRoute.to, params: {propKey: prop}, search: (search) => search}}
externalLinkDest={externalLinkDest()}
maybeHideDetails={true}
color="bg-red-50"

View File

@ -44,8 +44,8 @@ export default function Behaviours({ importedDataInView }) {
const user = useUserContext();
const adminAccess = ['owner', 'admin', 'super_admin'].includes(user.role)
const tabKey = `behavioursTab__${site.domain}`
const funnelKey = `behavioursTabFunnel__${site.domain}`
const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain)
const funnelKey = storage.getDomainScopedStorageKey('behavioursTabFunnel', site.domain)
const [enabledModes, setEnabledModes] = useState(getEnabledModes())
const [mode, setMode] = useState(defaultMode())
const [loading, setLoading] = useState(true)

View File

@ -9,6 +9,7 @@ import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasGoalFilter } f
import classNames from "classnames";
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { customPropsRoute } from "../../router";
export default function Properties({ afterFetchData }) {
@ -107,7 +108,7 @@ export default function Properties({ afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={chooseMetrics()}
detailsLink={url.sitePath(`custom-prop-values/${propKey}`)}
detailsLinkProps={{to: customPropsRoute.to, params: {propKey}, search: (search) => search}}
maybeHideDetails={true}
color="bg-red-50"
colMinWidth={90}

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'
import { Link } from '@tanstack/react-router'
import * as api from '../api'
import * as url from '../util/url'
import { Tooltip } from '../util/tooltip';
import { SecondsSinceLastLoad } from '../util/seconds-since-last-load';
import { useQueryContext } from '../query-context';
@ -15,7 +14,7 @@ export default function CurrentVisitors({ tooltipBoundary }) {
const updateCount = useCallback(() => {
api.get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`)
.then((res) => setCurrentVisitors(res))
}, [])
}, [site.domain])
useEffect(() => {
document.addEventListener('tick', updateCount)
@ -23,11 +22,11 @@ export default function CurrentVisitors({ tooltipBoundary }) {
return () => {
document.removeEventListener('tick', updateCount)
}
}, [])
}, [updateCount])
useEffect(() => {
updateCount()
}, [query])
}, [query, updateCount])
function tooltipInfo() {
return (
@ -41,7 +40,7 @@ export default function CurrentVisitors({ tooltipBoundary }) {
if (currentVisitors !== null && query.filters.length === 0) {
return (
<Tooltip info={tooltipInfo()} boundary={tooltipBoundary}>
<Link to={url.setQuery('period', 'realtime')} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
<Link search={(prev) => ({ ...prev, period: 'realtime' })} className="block ml-1 md:ml-2 mr-auto text-xs md:text-sm font-bold text-gray-500 dark:text-gray-300">
<svg className="inline w-2 mr-1 md:mr-2 text-green-500 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" />
</svg>

View File

@ -1,5 +1,5 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
import { useNavigate } from '@tanstack/react-router';
import Chart from 'chart.js/auto';
import { navigateToQuery } from '../../query'
import GraphTooltip from './graph-tooltip'
@ -8,6 +8,7 @@ import dateFormatter from './date-formatter';
import FadeIn from '../../fade-in';
import classNames from 'classnames';
import { hasGoalFilter } from '../../util/filters';
import { useQueryContext } from '../../query-context';
const calculateMaximumY = function(dataset) {
const yAxisValues = dataset
@ -225,9 +226,9 @@ class LineGraph extends React.Component {
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
if (this.props.graphData.interval === 'month') {
navigateToQuery(this.props.history, this.props.query, { period: 'month', date })
navigateToQuery(this.props.navigate, this.props.query, { period: 'month', date })
} else if (this.props.graphData.interval === 'date') {
navigateToQuery(this.props.history, this.props.query, { period: 'day', date })
navigateToQuery(this.props.navigate, this.props.query, { period: 'day', date })
}
}
@ -245,4 +246,8 @@ class LineGraph extends React.Component {
}
}
export default withRouter(LineGraph)
export default function LineGraphWrapped(props) {
const { query } = useQueryContext()
const navigate = useNavigate()
return <LineGraph {...props} navigate={navigate} query={query} />
}

View File

@ -151,10 +151,15 @@ export default function VisitorGraph({ updateImportedDataInView }) {
<div className="absolute right-4 -top-8 py-1 flex items-center">
{!isRealtime && <StatsExport />}
<SamplingNotice samplePercent={topStatData} />
<WithImportedSwitch info={topStatData && topStatData.with_imported_switch} />
{!!topStatData?.with_imported_switch && topStatData?.with_imported_switch.visible &&
<WithImportedSwitch
tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
disabled={!topStatData.with_imported_switch.togglable}
/>
}
<IntervalPicker onIntervalUpdate={onIntervalUpdate} />
</div>
<LineGraphWithRouter graphData={graphData} darkTheme={isDarkTheme} query={query} />
<LineGraphWithRouter graphData={graphData} darkTheme={isDarkTheme} />
</div>
</FadeIn>
</div>

View File

@ -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 <Link to={target}>{children}</Link>
} else {
return <div>{children}</div>
}
}
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": enabled,
"dark:text-gray-500 text-gray-400": !enabled,
})
const iconClass = classNames("mt-0.5", {
"dark:text-gray-300 text-gray-700": importsSwitchedOn,
"dark:text-gray-500 text-gray-400": !importsSwitchedOn,
})
return (
<div tooltip={tooltip_msg} className="w-4 h-4 mx-2">
<LinkOrDiv isLink={togglable} target={target}>
<BarsArrowUpIcon className={iconClass} />
</LinkOrDiv>
</div>
)
} else {
return null
}
return (
<div tooltip={tooltipMessage} className="w-4 h-4 mx-2">
<Link disabled={disabled} search={(search) => ({...search, with_imported: !importsSwitchedOn})}>
<BarsArrowUpIcon className={iconClass} />
</Link>
</div>
)
}

View File

@ -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 {
<h3 className="font-bold dark:text-gray-100">
{labelFor[this.state.mode] || 'Locations'}
</h3>
<ImportedQueryUnsupportedWarning loading={this.state.loading} query={this.props.query} skipImportedReason={this.state.skipImportedReason} />
<ImportedQueryUnsupportedWarning loading={this.state.loading} skipImportedReason={this.state.skipImportedReason} />
</div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{this.renderPill('Map', 'map')}

View File

@ -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 (
<>
<div className="mx-auto mt-4" style={{ width: '100%', maxWidth: '475px', height: '335px' }} id="map-container"></div>
<MoreLink list={this.state.countries} endpoint="countries" />
<MoreLink list={this.state.countries} linkProps={{to: countriesRoute.to, search: (search) => search}} />
{this.geolocationDbNotice()}
</>
)
@ -174,4 +174,4 @@ class Countries extends React.Component {
}
}
export default withRouter(Countries)
export default function CountriesWithRouter(props) {return (<Countries {...props} />)}

View File

@ -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({
<td className="p-2 truncate flex items-center group">
{maybeRenderIcon(item)}
<FilterLink
pathname={`/${encodeURIComponent(site.domain)}`}
to={rootRoute.to}
filterInfo={getFilterInfo(item)}
>
{trimURL(item.name, 40)}

View File

@ -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 (
<Modal maxWidth="460px">
<h1 className="text-xl font-bold dark:text-gray-100">Filter by {formatFilterGroup(this.state.modalType)}</h1>
<h1 className="text-xl font-bold dark:text-gray-100">
Filter by {formatFilterGroup(this.props.modalType)}
</h1>
<div className="mt-4 border-b border-gray-300"></div>
<main className="modal__content">
<form className="flex flex-col" onSubmit={this.handleSubmit.bind(this)}>
{FILTER_MODAL_TO_FILTER_GROUP[this.state.modalType].map((filterGroup) => (
{FILTER_MODAL_TO_FILTER_GROUP[this.props.modalType].map((filterGroup) => (
<FilterModalGroup
key={filterGroup}
filterGroup={filterGroup}
@ -169,7 +172,7 @@ class FilterModal extends React.Component {
}}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
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' : ''}
</button>
)}
</div>
@ -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 (
<FilterModal
{...props}
modalType={field || 'page'}
query={query}
navigate={navigate}
/>
)
}

View File

@ -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

View File

@ -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}
</div>
</div>
</div>,
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 <Modal {...props} navigate={navigate} location={location} />
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 (
<div className={`w-full text-center ${className ? className : ''}`}>
<Link
to={url || `/${endpoint}${window.location.search}`}
{...linkProps}
// eslint-disable-next-line max-len
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
onClick={onClick}

View File

@ -9,6 +9,7 @@ import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warni
import { hasGoalFilter } from '../../util/filters';
import { useQueryContext } from '../../query-context';
import { useSiteContext } from '../../site-context';
import { entryPagesRoute, exitPagesRoute, topPagesRoute } from '../../router';
function EntryPages({ afterFetchData }) {
const { query } = useQueryContext();
@ -42,7 +43,7 @@ function EntryPages({ afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel="Entry page"
metrics={chooseMetrics()}
detailsLink={url.sitePath('entry-pages')}
detailsLinkProps={{to: entryPagesRoute.to, search: (search) => 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"
/>

View File

@ -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 (
<Link to={linkTo} onClick={onClick} className={className}>
<Link to={to} onClick={onClick} className={className} search={(search) => {return {...search, filters: newFilters, labels: newLabels}}} >
{children}
</Link>
)
@ -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 && <MoreLink className={'mt-2'} url={detailsLink} list={state.list} />
const showDetails = !!detailsLinkProps && !state.loading && !hideDetails
return showDetails && <MoreLink className={'mt-2'} linkProps={detailsLinkProps} list={state.list} />
}
return (

View File

@ -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"

View File

@ -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 {
<React.Fragment>
<h3 className="font-bold dark:text-gray-100">Search Terms</h3>
{this.renderList()}
<MoreLink list={this.state.searchTerms} endpoint="referrers/Google" className="w-full pb-4 absolute bottom-0 left-0" />
<MoreLink list={this.state.searchTerms} linkProps={{to: referrersGoogleRoute.to, search: (search) => search}} className="w-full pb-4 absolute bottom-0 left-0" />
</React.Fragment>
)
}

View File

@ -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 })
}
@ -59,7 +59,7 @@ function AllSources({ afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel="Source"
metrics={chooseMetrics()}
detailsLink={url.sitePath('sources')}
detailsLinkProps={{to: sourcesRoute.to, search: (search) => 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 (
<ListReport
fetchData={fetchData}
@ -96,7 +105,7 @@ function UTMSources({ tab, afterFetchData }) {
getFilterFor={getFilterFor}
keyLabel={utmTag.label}
metrics={chooseMetrics()}
detailsLink={url.sitePath(utmTag.endpoint)}
detailsLinkProps={{to: route?.to, search: (search) => search}}
color="bg-blue-50"
/>
)

View File

@ -33,3 +33,5 @@ export function getItem(key) {
return memStore[key]
}
}
export const getDomainScopedStorageKey = (key, domain) => `${key}__${domain}`

View File

@ -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)

147
assets/package-lock.json generated
View File

@ -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"

View File

@ -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"
},