mirror of
https://github.com/plausible/analytics.git
synced 2024-12-24 10:02:10 +03:00
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:
parent
6ed4f3ad69
commit
28cf3ff2b2
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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 };
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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])}`
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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')}
|
||||
|
@ -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} />)}
|
||||
|
@ -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)}
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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 (
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
/>
|
||||
)
|
||||
|
@ -33,3 +33,5 @@ export function getItem(key) {
|
||||
return memStore[key]
|
||||
}
|
||||
}
|
||||
|
||||
export const getDomainScopedStorageKey = (key, domain) => `${key}__${domain}`
|
||||
|
@ -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
147
assets/package-lock.json
generated
@ -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"
|
||||
|
@ -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"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user