Adds types to dashboard query, makes keybinds route specific

This commit is contained in:
Artur Pata 2024-08-21 15:49:43 +03:00
parent 9c71161eab
commit 8c5988e39a
28 changed files with 1956 additions and 1028 deletions

View File

@ -1,222 +0,0 @@
import React, { Fragment, useState, useRef, useEffect } from 'react'
import { useAppNavigate } from './navigation/use-app-navigate'
import { navigateToQuery } from './query'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from './util/storage'
import Flatpickr from 'react-flatpickr'
import { parseNaiveDate, formatISO, formatDateRange } from './util/date'
import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
const COMPARISON_MODES = {
'off': 'Disable comparison',
'previous_period': 'Previous period',
'year_over_year': 'Year over year',
'custom': 'Custom period',
}
const DEFAULT_COMPARISON_MODE = 'previous_period'
export const COMPARISON_DISABLED_PERIODS = ['realtime', 'all']
const getMatchDayOfWeekStorageKey = (domain) => storage.getDomainScopedStorageKey('comparison_match_day_of_week', domain)
const storeMatchDayOfWeek = function (domain, matchDayOfWeek) {
storage.setItem(getMatchDayOfWeekStorageKey(domain), matchDayOfWeek.toString());
}
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(getComparisonModeStorageKey(domain), mode)
}
export const isComparisonEnabled = function (mode) {
return mode && mode !== "off"
}
export const toggleComparisons = function (navigate, query, site) {
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return
if (isComparisonEnabled(query.comparison)) {
storeComparisonMode(site.domain, "off")
navigateToQuery(navigate, query, { comparison: "off" })
} else {
const storedMode = getStoredComparisonMode(site.domain, null)
const newMode = isComparisonEnabled(storedMode) ? storedMode : DEFAULT_COMPARISON_MODE
storeComparisonMode(site.domain, newMode)
navigateToQuery(navigate, query, { comparison: newMode })
}
}
function ComparisonModeOption({ label, value, isCurrentlySelected, updateMode, setUiMode }) {
const click = () => {
if (value == "custom") {
setUiMode("datepicker")
} else {
updateMode(value)
}
}
const render = ({ active }) => {
const buttonClass = classNames("px-4 py-2 w-full text-left text-sm dark:text-white", {
"bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": active,
"font-medium": !isCurrentlySelected,
"font-bold": isCurrentlySelected,
})
return <button className={buttonClass}>{label}</button>
}
const disabled = isCurrentlySelected && value !== "custom"
return (
<Menu.Item key={value} onClick={click} disabled={disabled}>
{render}
</Menu.Item>
)
}
function MatchDayOfWeekInput() {
const navigate = useAppNavigate();
const site = useSiteContext()
const { query } = useQueryContext()
const click = (matchDayOfWeek) => {
storeMatchDayOfWeek(site.domain, matchDayOfWeek)
navigateToQuery(navigate, query, { match_day_of_week: matchDayOfWeek })
}
const buttonClass = (hover, selected) =>
classNames("px-4 py-2 w-full text-left text-sm dark:text-white cursor-pointer", {
"bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": hover,
"font-medium": !selected,
"font-bold": selected,
})
return <>
<Menu.Item key="match_day_of_week" onClick={() => click(true)}>
{({ active }) => (
<button className={buttonClass(active, query.match_day_of_week)}>Match day of the week</button>
)}
</Menu.Item>
<Menu.Item key="match_exact_date" onClick={() => click(false)}>
{({ active }) => (
<button className={buttonClass(active, !query.match_day_of_week)}>Match exact date</button>
)}
</Menu.Item>
</>
}
function ComparisonInput() {
const { query } = useQueryContext();
const site = useSiteContext();
const navigate = useAppNavigate();
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(navigate, query, { comparison: mode, compare_from: from, compare_to: to })
}
const buildLabel = (site, query) => {
if (query.comparison == "custom") {
return formatDateRange(site, query.compare_from, query.compare_to)
} else {
return COMPARISON_MODES[query.comparison]
}
}
const flatpickrOptions = {
mode: 'range',
showMonths: 1,
maxDate: 'today',
minDate: site.statsBegin,
animate: true,
static: true,
onClose: ([from, to], _dateStr, _instance) => {
setUiMode("menu")
if (from && to) {
[from, to] = [parseNaiveDate(from), parseNaiveDate(to)]
updateMode("custom", formatISO(from), formatISO(to))
}
}
}
return (
<>
<span className="hidden md:block pl-2 text-sm font-medium text-gray-800 dark:text-gray-200">vs.</span>
<div className="flex">
<div className="min-w-32 md:w-48 md:relative">
<Menu as="div" className="relative inline-block pl-2 w-full">
<Menu.Button className="bg-white text-gray-800 text-xs md:text-sm font-medium dark:bg-gray-800 dark:hover:bg-gray-900 dark:text-gray-200 hover:bg-gray-200 flex md:px-3 px-2 py-2 items-center justify-between leading-tight rounded shadow cursor-pointer w-full truncate">
<span className="truncate">{buildLabel(site, query)}</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500 ml-2" aria-hidden="true" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Menu.Items className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10" static>
{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 />
</span>}
</Menu.Items>
</Transition>
{uiMode == "datepicker" &&
<div className="h-0 md:absolute">
<Flatpickr ref={calendar} options={flatpickrOptions} className="invisible" />
</div>}
</Menu>
</div>
</div>
</>
)
}
export default ComparisonInput

View File

@ -0,0 +1,123 @@
/** @format */
import React, {
AriaAttributes,
DetailedHTMLProps,
forwardRef,
HTMLAttributes,
ReactNode
} from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { Transition } from '@headlessui/react'
import {
AppNavigationLink,
AppNavigationTarget
} from '../navigation/use-app-navigate'
export const ToggleDropdownButton = forwardRef<
HTMLDivElement,
{
currentOption: ReactNode
children: ReactNode
onClick: () => void
dropdownContainerProps: AriaAttributes
}
>(({ currentOption, children, onClick, dropdownContainerProps }, ref) => {
return (
<div className="min-w-32 md:w-48 md:relative" ref={ref}>
<button
onClick={onClick}
className="w-full flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-2 md:px-3
py-2 leading-tight cursor-pointer text-xs md:text-sm text-gray-800
dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900"
tabIndex={0}
aria-haspopup="true"
{...dropdownContainerProps}
>
<span className="truncate mr-1 md:mr-2">
<span className="font-medium">{currentOption}</span>
</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500" />
</button>
{children}
</div>
)
})
export const DropdownMenuWrapper = forwardRef<
HTMLDivElement,
{ innerContainerClassName?: string; children: ReactNode } & DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>
>(({ children, className, innerContainerClassName, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
className={classNames(
'absolute w-full left-0 right-0 md:w-56 md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10',
className
)}
>
<Transition
as="div"
show={true}
appear={true}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
className={classNames(
'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',
innerContainerClassName
)}
>
{children}
</Transition>
</div>
)
})
export const DropdownLinkGroup = ({
className,
children,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => (
<div
{...props}
className={classNames(
className,
'py-1 border-gray-200 dark:border-gray-500 border-b last:border-none'
)}
>
{children}
</div>
)
export const DropdownNavigationLink = ({
children,
active,
className,
...props
}: AppNavigationTarget & {
active?: boolean
children: ReactNode
className?: string
onClick?: () => void
}) => (
<AppNavigationLink
{...props}
className={classNames(
className,
{ 'font-bold': !!active },
'flex items-center justify-between',
`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`
)}
>
{children}
</AppNavigationLink>
)

View File

@ -0,0 +1,29 @@
/* @format */
import React from 'react'
import { NavigateKeybind } from './keybinding'
const ClearFiltersKeybind = () => (
<NavigateKeybind
keyboardKey="Escape"
type="keyup"
navigateProps={{
search: (search) =>
search.filters || search.labels
? {
...search,
filters: null,
labels: null,
keybindHint: 'Escape'
}
: search
}}
/>
)
export function DashboardKeybinds() {
return (
<>
<ClearFiltersKeybind />
</>
)
}

View File

@ -0,0 +1,64 @@
/* @format */
import React, { useEffect, useRef } from 'react'
import DatePicker from 'react-flatpickr'
export function DateRangeCalendar({
minDate,
maxDate,
defaultDates,
onCloseWithNoSelection,
onCloseWithSelection
}: {
minDate?: string
maxDate?: string
defaultDates?: [string, string]
onCloseWithNoSelection?: () => void
onCloseWithSelection?: ([selectionStart, selectionEnd]: [Date, Date]) => void
}) {
const calendarRef = useRef<DatePicker>(null)
useEffect(() => {
const calendar = calendarRef.current
if (calendar) {
calendar.flatpickr.open()
}
return () => {
calendar?.flatpickr?.destroy()
}
}, [])
return (
<div className="h-0 w-0">
<DatePicker
id="calendar"
options={{
mode: 'range',
maxDate,
minDate,
defaultDate: defaultDates,
showMonths: 1,
static: true,
animate: true
}}
ref={calendarRef}
onClose={
onCloseWithSelection || onCloseWithNoSelection
? ([selectionStart, selectionEnd]) => {
if (selectionStart && selectionEnd) {
if (onCloseWithSelection) {
onCloseWithSelection([selectionStart, selectionEnd])
}
} else {
if (onCloseWithNoSelection) {
onCloseWithNoSelection()
}
}
}
: undefined
}
className="invisible"
/>
</div>
)
}

View File

@ -1,456 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Fragment, useState, useEffect, useCallback, useRef } from "react";
import { useAppNavigate } from "./navigation/use-app-navigate";
import Flatpickr from "react-flatpickr";
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { Transition } from '@headlessui/react';
import {
shiftDays,
shiftMonths,
formatDay,
formatMonthYYYY,
formatYear,
formatISO,
isToday,
lastMonth,
nowForSite,
isSameMonth,
isThisMonth,
isThisYear,
parseUTCDate,
parseNaiveDate,
isBefore,
isAfter,
formatDateRange,
yesterday,
isSameDate
} from "./util/date";
import { navigateToQuery, QueryLink, QueryButton } from "./query";
import { shouldIgnoreKeypress } from "./keybinding";
import { COMPARISON_DISABLED_PERIODS, toggleComparisons, isComparisonEnabled } from "../dashboard/comparison-input";
import classNames from "classnames";
import { useQueryContext } from "./query-context";
import { useSiteContext } from "./site-context";
function KeyBindHint({children}) {
return (
<kbd className="rounded border border-gray-200 px-2 font-mono font-normal text-xs text-gray-400">{children}</kbd>
)
}
function renderArrow(query, site, period, prevDate, nextDate) {
const insertionDate = parseUTCDate(site.statsBegin);
const disabledLeft = isBefore(
parseUTCDate(prevDate),
insertionDate,
period
);
const disabledRight = isAfter(
parseUTCDate(nextDate),
nowForSite(site),
period
);
const isComparing = isComparisonEnabled(query.comparison)
const leftClass = classNames("flex items-center px-1 sm:px-2 border-r border-gray-300 rounded-l dark:border-gray-500 dark:text-gray-100", {
"bg-gray-300 dark:bg-gray-950": disabledLeft,
"hover:bg-gray-100 dark:hover:bg-gray-900": !disabledLeft,
})
const rightClass = classNames("flex items-center px-1 sm:px-2 rounded-r dark:text-gray-100", {
"bg-gray-300 dark:bg-gray-950": disabledRight,
"hover:bg-gray-100 dark:hover:bg-gray-900": !disabledRight,
})
const containerClass = classNames("rounded shadow bg-white mr-2 sm:mr-4 cursor-pointer dark:bg-gray-800", {
"hidden md:flex": isComparing,
"flex": !isComparing,
})
return (
<div className={containerClass}>
<QueryButton
search={{ date: prevDate }}
className={leftClass}
disabled={disabledLeft}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</QueryButton>
<QueryButton
search={{ date: nextDate }}
className={rightClass}
disabled={disabledRight}
>
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</QueryButton>
</div>
);
}
function DatePickerArrows() {
const { query } = useQueryContext();
const site = useSiteContext();
if (query.period === "year") {
const prevDate = formatISO(shiftMonths(query.date, -12));
const nextDate = formatISO(shiftMonths(query.date, 12));
return renderArrow(query, site, "year", prevDate, nextDate);
} else if (query.period === "month") {
const prevDate = formatISO(shiftMonths(query.date, -1));
const nextDate = formatISO(shiftMonths(query.date, 1));
return renderArrow(query, site, "month", prevDate, nextDate);
} else if (query.period === "day") {
const prevDate = formatISO(shiftDays(query.date, -1));
const nextDate = formatISO(shiftDays(query.date, 1));
return renderArrow(query, site, "day", prevDate, nextDate);
}
return null
}
function DisplayPeriod() {
const { query } = useQueryContext();
const site = useSiteContext();
if (query.period === "day") {
if (isToday(site, query.date)) {
return "Today";
}
return formatDay(query.date);
} if (query.period === '7d') {
return 'Last 7 days'
} if (query.period === '30d') {
return 'Last 30 days'
} if (query.period === 'month') {
if (isThisMonth(site, query.date)) {
return 'Month to Date'
}
return formatMonthYYYY(query.date)
} if (query.period === '6mo') {
return 'Last 6 months'
} if (query.period === '12mo') {
return 'Last 12 months'
} if (query.period === 'year') {
if (isThisYear(site, query.date)) {
return 'Year to Date'
}
return formatYear(query.date)
} if (query.period === 'all') {
return 'All time'
} if (query.period === 'custom') {
return formatDateRange(site, query.from, query.to)
}
return 'Realtime'
}
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 = useAppNavigate();
const handleKeydown = useCallback((e) => {
if (shouldIgnoreKeypress(e)) return true
const newSearch = {
period: null,
from: null,
to: null,
date: null
};
const insertionDate = parseUTCDate(site.statsBegin);
if (e.key === "ArrowLeft") {
const prevDate = formatISO(shiftDays(query.date, -1));
const prevMonth = formatISO(shiftMonths(query.date, -1));
const prevYear = formatISO(shiftMonths(query.date, -12));
if (query.period === "day" && !isBefore(parseUTCDate(prevDate), insertionDate, query.period)) {
newSearch.period = "day";
newSearch.date = prevDate;
} else if (query.period === "month" && !isBefore(parseUTCDate(prevMonth), insertionDate, query.period)) {
newSearch.period = "month";
newSearch.date = prevMonth;
} else if (query.period === "year" && !isBefore(parseUTCDate(prevYear), insertionDate, query.period)) {
newSearch.period = "year";
newSearch.date = prevYear;
}
} else if (e.key === "ArrowRight") {
const now = nowForSite(site)
const nextDate = formatISO(shiftDays(query.date, 1));
const nextMonth = formatISO(shiftMonths(query.date, 1));
const nextYear = formatISO(shiftMonths(query.date, 12));
if (query.period === "day" && !isAfter(parseUTCDate(nextDate), now, query.period)) {
newSearch.period = "day";
newSearch.date = nextDate;
} else if (query.period === "month" && !isAfter(parseUTCDate(nextMonth), now, query.period)) {
newSearch.period = "month";
newSearch.date = nextMonth;
} else if (query.period === "year" && !isAfter(parseUTCDate(nextYear), now, query.period)) {
newSearch.period = "year";
newSearch.date = nextYear;
}
}
setOpen(false);
const keybindings = {
d: { date: null, period: 'day' },
e: { date: formatISO(shiftDays(nowForSite(site), -1)), period: 'day' },
r: { period: 'realtime' },
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(navigate, query, { ...newSearch, ...redirect, keybindHint: e.key.toUpperCase() })
} else if (e.key.toLowerCase() === 'x') {
toggleComparisons(navigate, query, site)
} else if (e.key.toLowerCase() === 'c') {
setOpen(true)
setMode('calendar')
} else if (newSearch.date) {
navigateToQuery(navigate, query, newSearch);
}
}, [query])
const handleClick = useCallback((e) => {
if (dropDownNode.current && dropDownNode.current.contains(e.target)) return;
setOpen(false)
})
useEffect(() => {
if (mode === 'calendar' && open) {
openCalendar()
}
}, [mode])
useEffect(() => {
document.addEventListener("keydown", handleKeydown);
return () => { document.removeEventListener("keydown", handleKeydown); }
}, [handleKeydown])
useEffect(() => {
document.addEventListener("mousedown", handleClick, false);
return () => { document.removeEventListener("mousedown", handleClick, false); }
}, [])
function setCustomDate([from, to], _dateStr, _instance) {
if (from && to) {
[from, to] = [parseNaiveDate(from), parseNaiveDate(to)]
if (from.isSame(to)) {
navigateToQuery(navigate, query, { period: 'day', date: formatISO(from), from: null, to: null })
} else {
navigateToQuery(navigate, query, { period: 'custom', date: null, from: formatISO(from), to: formatISO(to) })
}
}
setOpen(false)
}
function toggle() {
const newMode = mode === 'calendar' && !open ? 'menu' : mode
setOpen(!open)
setMode(newMode)
}
function openCalendar() {
calendar.current?.flatpickr.open();
}
function renderLink(period, text, opts = {}) {
let boldClass;
if (query.period === "day" && period === "day") {
boldClass = isSameDate(opts.date, query.date) ? "font-bold" : "";
} else if (query.period === "month" && period === "month") {
const linkDate = opts.date || nowForSite(site);
boldClass = isSameMonth(linkDate, query.date) ? "font-bold" : "";
} else {
boldClass = query.period === period ? "font-bold" : "";
}
opts.date = opts.date ? formatISO(opts.date) : null;
return (
<QueryLink
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`}
>
{text}
{opts.keybindHint ? (<KeyBindHint>{opts.keybindHint}</KeyBindHint>) : null}
</QueryLink>
);
}
function renderDropDownContent() {
if (mode === "menu") {
return (
<div
data-testid="datemenu"
id="datemenu"
className="absolute w-full left-0 right-0 md:w-56 md:absolute md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10"
>
<div
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 date-options"
>
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
{renderLink("day", "Today", { keybindHint: 'D', date: nowForSite(site) })}
{renderLink("day", "Yesterday", { keybindHint: 'E', date: yesterday(site) })}
{renderLink("realtime", "Realtime", { keybindHint: 'R' })}
</div>
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
{renderLink("7d", "Last 7 Days", { keybindHint: 'W' })}
{renderLink("30d", "Last 30 Days", { keybindHint: 'T' })}
</div>
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
{renderLink('month', 'Month to Date', { keybindHint: 'M' })}
{renderLink('month', 'Last Month', { date: lastMonth(site) })}
</div>
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
{renderLink("year", "Year to Date", { keybindHint: 'Y' })}
{renderLink("12mo", "Last 12 months", { keybindHint: 'L' })}
</div>
<div className="py-1 date-option-group">
{renderLink("all", "All time", { keybindHint: 'A' })}
<span
onClick={() => setMode('calendar')}
onKeyPress={() => setMode('calendar')}
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"
tabIndex="0"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="calendar"
>
Custom Range
<KeyBindHint>C</KeyBindHint>
</span>
</div>
{!COMPARISON_DISABLED_PERIODS.includes(query.period) &&
<div className="py-1 date-option-group border-t border-gray-200 dark:border-gray-500">
<span
onClick={() => {
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">
{isComparisonEnabled(query.comparison) ? 'Disable comparison' : 'Compare'}
<KeyBindHint>X</KeyBindHint>
</span>
</div>}
</div>
</div>
);
} if (mode === "calendar") {
return (
<div className="h-0">
<Flatpickr
id="calendar"
options={{
mode: 'range',
maxDate: 'today',
minDate: site.statsBegin,
showMonths: 1,
static: true,
animate: true
}}
ref={calendar}
className="invisible"
onClose={setCustomDate}
/>
</div>
)
}
}
function renderPicker() {
return (
<div
className="min-w-32 md:w-48 md:relative"
ref={dropDownNode}
>
<div
onClick={toggle}
className="flex items-center justify-between rounded bg-white dark:bg-gray-800 shadow px-2 md:px-3
py-2 leading-tight cursor-pointer text-xs md:text-sm text-gray-800
dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900"
tabIndex="0"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="datemenu"
>
<span className="truncate mr-1 md:mr-2">
<span className="font-medium"><DisplayPeriod /></span>
</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500" />
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
{renderDropDownContent()}
</Transition>
</div>
);
}
return (
<div className="flex ml-auto pl-2">
<DatePickerArrows />
{renderPicker()}
</div>
)
}
export default DatePicker

View File

@ -0,0 +1,447 @@
/* @format */
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { formatDateRange, formatISO } from './util/date'
import {
shiftQueryPeriod,
getDateForShiftedPeriod,
clearedComparisonSearch
} from './query'
import classNames from 'classnames'
import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { Keybind, KeybindHint, NavigateKeybind } from './keybinding'
import {
AppNavigationLink,
useAppNavigate
} from './navigation/use-app-navigate'
import { DateRangeCalendar } from './date-range-calendar'
import {
COMPARISON_DISABLED_PERIODS,
COMPARISON_MODES,
ComparisonMode,
DisplaySelectedPeriod,
getCompareLinkItem,
isComparisonEnabled,
getSearchToApplyCustomComparisonDates,
getSearchToApplyCustomDates,
QueryPeriod,
last6MonthsLinkItem,
getDatePeriodGroups,
LinkItem,
COMPARISON_MATCH_MODE_LABELS,
ComparisonMatchMode
} from './query-time-periods'
import { useOnClickOutside } from './util/use-on-click-outside'
import {
DropdownLinkGroup,
DropdownMenuWrapper,
DropdownNavigationLink,
ToggleDropdownButton
} from './components/dropdown'
import { useMatch } from 'react-router-dom'
import { rootRoute } from './router'
const ArrowKeybind = ({
keyboardKey
}: {
keyboardKey: 'ArrowLeft' | 'ArrowRight'
}) => {
const site = useSiteContext()
const { query } = useQueryContext()
const search = useMemo(
() =>
shiftQueryPeriod({
query,
site,
direction: ({ ArrowLeft: -1, ArrowRight: 1 } as const)[keyboardKey],
keybindHint: keyboardKey
}),
[site, query, keyboardKey]
)
return (
<NavigateKeybind
type="keydown"
keyboardKey={keyboardKey}
navigateProps={{ search }}
/>
)
}
function ArrowIcon({ direction }: { direction: 'left' | 'right' }) {
return (
<svg
className="feather h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{direction === 'left' && <polyline points="15 18 9 12 15 6"></polyline>}
{direction === 'right' && <polyline points="9 18 15 12 9 6"></polyline>}
</svg>
)
}
function MovePeriodArrows() {
const periodsWithArrows = [
QueryPeriod.year,
QueryPeriod.month,
QueryPeriod.day
]
const { query } = useQueryContext()
const site = useSiteContext()
if (!periodsWithArrows.includes(query.period)) {
return null
}
const canGoBack =
getDateForShiftedPeriod({ site, query, direction: -1 }) !== null
const canGoForward =
getDateForShiftedPeriod({ site, query, direction: 1 }) !== null
const isComparing = isComparisonEnabled(query.comparison)
const sharedClass = 'flex items-center px-1 sm:px-2 dark:text-gray-100'
const enabledClass = 'hover:bg-gray-100 dark:hover:bg-gray-900'
const disabledClass = 'bg-gray-300 dark:bg-gray-950 cursor-not-allowed'
const containerClass = classNames(
'rounded shadow bg-white mr-2 sm:mr-4 cursor-pointer dark:bg-gray-800',
{
'hidden md:flex': isComparing,
flex: !isComparing
}
)
return (
<div className={containerClass}>
<AppNavigationLink
className={classNames(
sharedClass,
'rounded-l border-gray-300 dark:border-gray-500',
{ [enabledClass]: canGoBack, [disabledClass]: !canGoBack }
)}
search={
canGoBack
? shiftQueryPeriod({
site,
query,
direction: -1,
keybindHint: null
})
: (search) => search
}
>
<ArrowIcon direction="left" />
</AppNavigationLink>
<AppNavigationLink
className={classNames(sharedClass, {
[enabledClass]: canGoForward,
[disabledClass]: !canGoForward
})}
search={
canGoForward
? shiftQueryPeriod({
site,
query,
direction: 1,
keybindHint: null
})
: (search) => search
}
>
<ArrowIcon direction="right" />
</AppNavigationLink>
</div>
)
}
function ComparisonMenu({
toggleCompareMenuCalendar
}: {
toggleCompareMenuCalendar: () => void
}) {
const { query } = useQueryContext()
return (
<DropdownMenuWrapper id="compare-menu" data-testid="compare-menu">
<DropdownLinkGroup>
{[
ComparisonMode.off,
ComparisonMode.previous_period,
ComparisonMode.year_over_year
].map((comparisonMode) => (
<DropdownNavigationLink
key={comparisonMode}
active={query.comparison === comparisonMode}
search={(search) => ({
...search,
...clearedComparisonSearch,
comparison: comparisonMode
})}
>
{COMPARISON_MODES[comparisonMode]}
</DropdownNavigationLink>
))}
<DropdownNavigationLink
active={query.comparison === ComparisonMode.custom}
search={(s) => s}
onClick={toggleCompareMenuCalendar}
>
{COMPARISON_MODES[ComparisonMode.custom]}
</DropdownNavigationLink>
</DropdownLinkGroup>
{query.comparison !== ComparisonMode.custom && (
<DropdownLinkGroup>
<DropdownNavigationLink
active={query.match_day_of_week === true}
search={(s) => ({ ...s, match_day_of_week: true })}
>
{COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchDayOfWeek]}
</DropdownNavigationLink>
<DropdownNavigationLink
active={query.match_day_of_week === false}
search={(s) => ({ ...s, match_day_of_week: false })}
>
{COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchExactDate]}
</DropdownNavigationLink>
</DropdownLinkGroup>
)}
</DropdownMenuWrapper>
)
}
function QueryPeriodsMenu({
groups,
closeMenu
}: {
groups: LinkItem[][]
closeMenu: () => void
}) {
const site = useSiteContext()
const { query } = useQueryContext()
return (
<DropdownMenuWrapper
id="datemenu"
data-testid="datemenu"
innerContainerClassName="date-options"
>
{groups.map((group, index) => (
<DropdownLinkGroup key={index} className="date-options-group">
{group.map(
([[label, keyboardKey], { search, isActive, onClick }]) => (
<DropdownNavigationLink
key={label}
active={isActive({ site, query })}
search={search}
onClick={onClick || closeMenu}
>
{label}
{!!keyboardKey && <KeybindHint>{keyboardKey}</KeybindHint>}
</DropdownNavigationLink>
)
)}
</DropdownLinkGroup>
))}
</DropdownMenuWrapper>
)
}
export default function QueryPeriodPicker() {
const site = useSiteContext()
const { query } = useQueryContext()
const navigate = useAppNavigate()
const [menuVisible, setMenuVisible] = useState<
| 'datemenu'
| 'datemenu-calendar'
| 'compare-menu'
| 'compare-menu-calendar'
| null
>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const compareDropdownRef = useRef<HTMLDivElement>(null)
const dashboardRouteMatch = useMatch(rootRoute.path)
const closeMenu = useCallback(() => {
setMenuVisible(null)
}, [])
const toggleDateMenu = useCallback(() => {
setMenuVisible((prevState) =>
prevState === 'datemenu' ? null : 'datemenu'
)
}, [])
const toggleCompareMenu = useCallback(() => {
setMenuVisible((prevState) =>
prevState === 'compare-menu' ? null : 'compare-menu'
)
}, [])
const toggleDateMenuCalendar = useCallback(() => {
setMenuVisible((prevState) =>
prevState === 'datemenu-calendar' ? null : 'datemenu-calendar'
)
}, [])
const toggleCompareMenuCalendar = useCallback(() => {
setMenuVisible((prevState) =>
prevState === 'compare-menu-calendar' ? null : 'compare-menu-calendar'
)
}, [])
const customRangeLink: LinkItem = useMemo(
() => [
['Custom Range', 'C'],
{
search: (s) => s,
isActive: ({ query }) => query.period === QueryPeriod.custom,
onClick: toggleDateMenuCalendar
}
],
[toggleDateMenuCalendar]
)
const compareLink: LinkItem = useMemo(
() => getCompareLinkItem({ site, query }),
[site, query]
)
const groups = useMemo(() => {
const groups = getDatePeriodGroups(site)
// add Custom Range link to the last group
groups[groups.length - 1].push(customRangeLink)
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) {
return groups
}
// maybe ass Compare link as another group to the very end
return groups.concat([[compareLink]])
}, [site, query, customRangeLink, compareLink])
useOnClickOutside({
ref: dropdownRef,
active: menuVisible === 'datemenu',
handler: closeMenu
})
useOnClickOutside({
ref: compareDropdownRef,
active: menuVisible === 'compare-menu',
handler: closeMenu
})
useEffect(() => {
closeMenu()
}, [closeMenu, query])
return (
<div className="flex ml-auto pl-2">
<MovePeriodArrows />
<ToggleDropdownButton
currentOption={<DisplaySelectedPeriod />}
ref={dropdownRef}
onClick={toggleDateMenu}
dropdownContainerProps={{
['aria-controls']: 'datemenu',
['aria-expanded']: menuVisible === 'datemenu'
}}
>
{menuVisible === 'datemenu' && (
<QueryPeriodsMenu groups={groups} closeMenu={closeMenu} />
)}
{menuVisible === 'datemenu-calendar' && (
<DateRangeCalendar
onCloseWithSelection={(selection) =>
navigate({ search: getSearchToApplyCustomDates(selection) })
}
minDate={site.statsBegin}
defaultDates={
query.to && query.from
? [formatISO(query.from), formatISO(query.to)]
: undefined
}
/>
)}
</ToggleDropdownButton>
{isComparisonEnabled(query.comparison) && (
<>
<div className="my-auto px-1 text-sm font-medium text-gray-800 dark:text-gray-200">
<span className="hidden md:inline px-1">vs.</span>
</div>
<ToggleDropdownButton
ref={compareDropdownRef}
currentOption={
query.comparison === ComparisonMode.custom &&
query.compare_from &&
query.compare_to
? formatDateRange(site, query.compare_from, query.compare_to)
: COMPARISON_MODES[query.comparison]
}
onClick={toggleCompareMenu}
dropdownContainerProps={{
['aria-controls']: 'compare-menu',
['aria-expanded']: menuVisible === 'compare-menu'
}}
>
{menuVisible === 'compare-menu' && (
<ComparisonMenu
toggleCompareMenuCalendar={toggleCompareMenuCalendar}
/>
)}
{menuVisible === 'compare-menu-calendar' && (
<DateRangeCalendar
onCloseWithSelection={(selection) =>
navigate({
search: getSearchToApplyCustomComparisonDates(selection)
})
}
minDate={site.statsBegin}
defaultDates={
query.compare_from && query.compare_to
? [
formatISO(query.compare_from),
formatISO(query.compare_to)
]
: undefined
}
/>
)}
</ToggleDropdownButton>
</>
)}
{!!dashboardRouteMatch && (
<>
<ArrowKeybind keyboardKey="ArrowLeft" />
<ArrowKeybind keyboardKey="ArrowRight" />
{groups
.concat([[last6MonthsLinkItem]])
.flatMap((group) =>
group
.filter(([[_name, keyboardKey]]) => !!keyboardKey)
.map(([[_name, keyboardKey], { search, onClick, isActive }]) =>
onClick || isActive({ site, query }) ? (
<Keybind
key={keyboardKey}
keyboardKey={keyboardKey}
type="keydown"
handler={onClick || closeMenu}
/>
) : (
<NavigateKeybind
key={keyboardKey}
keyboardKey={keyboardKey}
type="keydown"
navigateProps={{ search }}
/>
)
)
)}
</>
)}
</div>
)
}

View File

@ -7,7 +7,6 @@ import { AdjustmentsVerticalIcon, MagnifyingGlassIcon, XMarkIcon, PencilSquareIc
import classNames from 'classnames';
import { Menu, Transition } from '@headlessui/react';
import { navigateToQuery } from './query';
import {
FILTER_GROUP_TO_MODAL_TYPE,
cleanLabels,
@ -24,19 +23,23 @@ function removeFilter(filterIndex, navigate, query) {
const newFilters = query.filters.filter((_filter, index) => filterIndex != index)
const newLabels = cleanLabels(newFilters, query.labels)
navigateToQuery(
navigate,
query,
{ filters: newFilters, labels: newLabels }
)
navigate({
search: ({ search }) => ({
...search,
filters: newFilters,
labels: newLabels
})
})
}
function clearAllFilters(navigate, query) {
navigateToQuery(
navigate,
query,
{ filters: null, labels: null }
);
function clearAllFilters(navigate) {
navigate({
search: ({ search }) => ({
...search,
filters: null,
labels: null
})
})
}
function AppliedFilterPillVertical({filterIndex, filter}) {
@ -112,7 +115,7 @@ function DropdownContent({ wrapped }) {
</div>
{query.filters.map((filter, index) => <AppliedFilterPillVertical key={index} filterIndex={index} filter={filter}/>)}
<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(navigate, 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)}>
Clear All Filters
</div>
</Menu.Item>
@ -131,11 +134,9 @@ function Filters() {
handleResize()
window.addEventListener('resize', handleResize, false)
document.addEventListener('keyup', handleKeyup)
return () => {
window.removeEventListener('resize', handleResize, false)
document.removeEventListener("keyup", handleKeyup)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -149,15 +150,6 @@ function Filters() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wrapped])
function handleKeyup(e) {
if (e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === 'Escape') {
clearAllFilters(navigate, query)
}
}
function handleResize() {
setViewport(window.innerWidth || 639)
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import Datepicker from './datepicker'
import QueryPeriodPicker from './datepicker'
import SiteSwitcher from './site-switcher'
import Filters from './filters'
import CurrentVisitors from './stats/current-visitors'
@ -10,7 +10,6 @@ import Pages from './stats/pages'
import Locations from './stats/locations';
import Devices from './stats/devices'
import Behaviours from './stats/behaviours'
import ComparisonInput from './comparison-input'
import { withPinnedHeader } from './pinned-header-hoc';
import { statsBoxClass } from './index';
import { useSiteContext } from './site-context';
@ -33,8 +32,7 @@ function Historical({ stuck, importedDataInView, updateImportedDataInView }) {
<CurrentVisitors tooltipBoundary={tooltipBoundary.current} />
<Filters className="flex" />
</div>
<Datepicker />
<ComparisonInput />
<QueryPeriodPicker />
</div>
</div>
<VisitorGraph updateImportedDataInView={updateImportedDataInView} />

View File

@ -1,34 +0,0 @@
/**
* Returns whether a keydown or keyup event should be ignored or not.
*
* Keybindings are ignored when a modifier key is pressed, for example, if the
* keybinding is <i>, but the user pressed <Ctrl-i> or <Meta-i>, the event
* should be discarded.
*
* Another case for ignoring a keybinding, is when the user is typing into a
* form, and presses the keybinding. For example, if the keybinding is <p> and
* the user types <apple>, the event should also be discarded.
*
* @param {*} event - Captured HTML DOM event
* @return {boolean} Whether the event should be ignored or not.
*
*/
export function shouldIgnoreKeypress(event) {
const modifierPressed = event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229
const isTyping = event.isComposing || event.target.tagName == "INPUT" || event.target.tagName == "TEXTAREA"
return modifierPressed || isTyping
}
/**
* Returns whether the given keybinding has been pressed and should be
* processed. Events can be ignored based on `shouldIgnoreKeypress(event)`.
*
* @param {string} keybinding - The target key to checked, e.g. `"i"`.
* @return {boolean} Whether the event should be processed or not.
*
*/
export function isKeyPressed(event, keybinding) {
const keyPressed = event.key.toLowerCase() == keybinding.toLowerCase()
return keyPressed && !shouldIgnoreKeypress(event)
}

View File

@ -0,0 +1,109 @@
/* @format */
import React, { ReactNode, useCallback, useEffect } from 'react'
import {
AppNavigationTarget,
useAppNavigate
} from './navigation/use-app-navigate'
/**
* Returns whether a keydown or keyup event should be ignored or not.
*
* Keybindings are ignored when a modifier key is pressed, for example, if the
* keybinding is <i>, but the user pressed <Ctrl-i> or <Meta-i>, the event
* should be discarded.
*
* Another case for ignoring a keybinding, is when the user is typing into a
* form, and presses the keybinding. For example, if the keybinding is <p> and
* the user types <apple>, the event should also be discarded.
*
* @param {*} event - Captured HTML DOM event
* @return {boolean} Whether the event should be ignored or not.
*
*/
export function shouldIgnoreKeypress(event: KeyboardEvent) {
const targetElement = event.target as Element | undefined
const modifierPressed =
event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229
const isTyping =
event.isComposing ||
targetElement?.tagName == 'INPUT' ||
targetElement?.tagName == 'TEXTAREA'
return modifierPressed || isTyping
}
/**
* Returns whether the given keybinding has been pressed and should be
* processed. Events can be ignored based on `shouldIgnoreKeypress(event)`.
*
* @param {string} keyboardKey - The target key to checked, e.g. `"i"`.
* @return {boolean} Whether the event should be processed or not.
*
*/
export function isKeyPressed(event: KeyboardEvent, keyboardKey: string) {
const keyPressed = event.key.toLowerCase() == keyboardKey.toLowerCase()
return keyPressed && !shouldIgnoreKeypress(event)
}
type KeyboardEventType = keyof Pick<
GlobalEventHandlersEventMap,
'keyup' | 'keydown' | 'keypress'
>
export function Keybind({
keyboardKey,
type,
handler
}: {
keyboardKey: string
type: KeyboardEventType
handler: () => void
}) {
const wrappedHandler = useCallback(
(event: KeyboardEvent) => {
if (isKeyPressed(event, keyboardKey)) {
handler()
}
},
[keyboardKey, handler]
)
useEffect(() => {
const registerKeybind = () =>
document.addEventListener(type, wrappedHandler)
const deregisterKeybind = () =>
document.removeEventListener(type, wrappedHandler)
registerKeybind()
return deregisterKeybind
}, [type, wrappedHandler])
return null
}
export function NavigateKeybind({
keyboardKey,
type,
navigateProps
}: {
keyboardKey: string
type: 'keyup' | 'keydown' | 'keypress'
navigateProps: AppNavigationTarget
}) {
const navigate = useAppNavigate()
const handler = useCallback(() => {
navigate({ ...navigateProps })
}, [navigateProps, navigate])
return <Keybind keyboardKey={keyboardKey} type={type} handler={handler} />
}
export function KeybindHint({ children }: { children: ReactNode }) {
return (
<kbd className="rounded border border-gray-200 dark:border-gray-600 px-2 font-mono font-normal text-xs text-gray-400">
{children}
</kbd>
)
}

View File

@ -11,7 +11,7 @@ import {
} from 'react-router-dom'
import { parseSearch, stringifySearch } from '../util/url'
type AppNavigationTarget = {
export type AppNavigationTarget = {
/**
* path to target, for example `"/posts"` or `"/posts/:id"`
*/

View File

@ -1,41 +0,0 @@
import React, { createContext, useMemo, useEffect, useContext, useState, useCallback } from "react";
import { parseQuery } from "./query";
import { useLocation } from "react-router";
import { useMountedEffect } from "./custom-hooks";
import * as api from './api'
import { useSiteContext } from "./site-context";
import { parseSearch } from "./util/url";
const queryContextDefaultValue = { query: {}, lastLoadTimestamp: new Date() }
const QueryContext = createContext(queryContextDefaultValue)
export const useQueryContext = () => { return useContext(QueryContext) }
export default function QueryContextProvider({ children }) {
const location = useLocation();
const site = useSiteContext();
const searchRecord = useMemo(() => parseSearch(location.search), [location.search]);
const query = useMemo(() => {
return parseQuery(searchRecord, site)
}, [searchRecord, site])
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
const updateLastLoadTimestamp = useCallback(() => { setLastLoadTimestamp(new Date()) }, [setLastLoadTimestamp])
useEffect(() => {
document.addEventListener('tick', updateLastLoadTimestamp)
return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [updateLastLoadTimestamp])
useMountedEffect(() => {
api.cancelAll()
updateLastLoadTimestamp()
}, [])
return <QueryContext.Provider value={{ query, lastLoadTimestamp }}>{children}</QueryContext.Provider>
};

View File

@ -0,0 +1,151 @@
/* @format */
import React, {
createContext,
useMemo,
useEffect,
useContext,
useState,
useCallback,
ReactNode
} from 'react'
import { useLocation } from 'react-router'
import { useMountedEffect } from './custom-hooks'
import * as api from './api'
import { useSiteContext } from './site-context'
import { parseSearch } from './util/url'
import dayjs from 'dayjs'
import { nowForSite, yesterday } from './util/date'
import {
getDashboardTimeSettings,
getSavedTimePreferencesFromStorage,
QueryPeriod,
useSaveTimePreferencesToStorage
} from './query-time-periods'
import { Filter, FilterClauseLabels, queryDefaultValue } from './query'
const queryContextDefaultValue = {
query: queryDefaultValue,
otherSearch: {} as Record<string, unknown>,
lastLoadTimestamp: new Date()
}
export type QueryContextValue = typeof queryContextDefaultValue
const QueryContext = createContext(queryContextDefaultValue)
export const useQueryContext = () => {
return useContext(QueryContext)
}
export default function QueryContextProvider({
children
}: {
children: ReactNode
}) {
const location = useLocation()
const site = useSiteContext()
const {
compare_from,
compare_to,
comparison,
date,
filters,
from,
labels,
match_day_of_week,
period,
to,
with_imported,
...otherSearch
} = useMemo(() => parseSearch(location.search), [location.search])
const query = useMemo(() => {
const defaultValues = queryDefaultValue
const storedValues = getSavedTimePreferencesFromStorage({ site })
const timeQuery = getDashboardTimeSettings({
searchValues: { period, comparison, match_day_of_week },
storedValues,
defaultValues
})
return {
...timeQuery,
compare_from:
typeof compare_from === 'string' && compare_from.length
? dayjs.utc(compare_from)
: defaultValues.compare_from,
compare_to:
typeof compare_to === 'string' && compare_to.length
? dayjs.utc(compare_to)
: defaultValues.compare_to,
date:
typeof date === 'string' && date.length
? dayjs.utc(date)
: nowForSite(site),
from:
typeof from === 'string' && from.length
? dayjs.utc(from)
: timeQuery.period === QueryPeriod.custom
? yesterday(site)
: defaultValues.from,
to:
typeof to === 'string' && to.length
? dayjs.utc(to)
: timeQuery.period === QueryPeriod.custom
? nowForSite(site)
: defaultValues.to,
with_imported: [true, false].includes(with_imported as boolean)
? (with_imported as boolean)
: defaultValues.with_imported,
filters: Array.isArray(filters)
? (filters as Filter[])
: defaultValues.filters,
labels: (labels as FilterClauseLabels) || defaultValues.labels
}
}, [
compare_from,
compare_to,
comparison,
date,
filters,
from,
labels,
match_day_of_week,
period,
to,
with_imported,
site
])
useSaveTimePreferencesToStorage({
site,
period,
comparison,
match_day_of_week
})
const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
const updateLastLoadTimestamp = useCallback(() => {
setLastLoadTimestamp(new Date())
}, [setLastLoadTimestamp])
useEffect(() => {
document.addEventListener('tick', updateLastLoadTimestamp)
return () => {
document.removeEventListener('tick', updateLastLoadTimestamp)
}
}, [updateLastLoadTimestamp])
useMountedEffect(() => {
api.cancelAll()
updateLastLoadTimestamp()
}, [])
return (
<QueryContext.Provider value={{ query, otherSearch, lastLoadTimestamp }}>
{children}
</QueryContext.Provider>
)
}

View File

@ -33,16 +33,12 @@ test('if no period is stored, loads with default value of "Last 30 days", all ex
['Month to Date', 'M'],
['Last Month', ''],
['Year to Date', 'Y'],
['Last 12 months', 'L'],
['All time', 'A']
['Last 12 Months', 'L'],
['All time', 'A'],
['Custom Range', 'C'],
['Compare', 'X']
].map((a) => a.join(''))
)
expect(screen.getByText('Custom Range').textContent).toEqual(
['Custom Range', 'C'].join('')
)
expect(screen.getByText('Compare').textContent).toEqual(
['Compare', 'X'].join('')
)
})
test('user can select a new period and its value is stored', async () => {
@ -59,7 +55,7 @@ test('user can select a new period and its value is stored', async () => {
expect(localStorage.getItem(periodStorageKey)).toBe('all')
})
test('stored period "all" is respected, and Compare option is not present for it in menu', async () => {
test('period "all" is respected, and Compare option is not present for it in menu', async () => {
localStorage.setItem(periodStorageKey, 'all')
render(<DatePicker />, {

View File

@ -0,0 +1,92 @@
/** @format */
import {
ComparisonMode,
getDashboardTimeSettings,
getPeriodStorageKey,
getStoredPeriod,
QueryPeriod
} from './query-time-periods'
describe(`${getStoredPeriod.name}`, () => {
const domain = 'any.site'
const key = getPeriodStorageKey(domain)
it('returns fallback value if invalid values stored', () => {
localStorage.setItem(key, 'any-invalid-value')
expect(getStoredPeriod(domain, null)).toEqual(null)
})
it('returns correct value if value stored', () => {
localStorage.setItem(key, QueryPeriod['7d'])
expect(getStoredPeriod(domain, null)).toEqual(QueryPeriod['7d'])
})
})
describe(`${getDashboardTimeSettings.name}`, () => {
const defaultValues = {
period: QueryPeriod['7d'],
comparison: null,
match_day_of_week: true
}
const emptySearchValues = {
period: undefined,
comparison: undefined,
match_day_of_week: undefined
}
const emptyStoredValues = {
period: null,
comparison: null,
match_day_of_week: null
}
it('returns defaults if nothing stored and no search', () => {
expect(
getDashboardTimeSettings({
searchValues: emptySearchValues,
storedValues: emptyStoredValues,
defaultValues
})
).toEqual(defaultValues)
})
it('returns stored values if no search', () => {
expect(
getDashboardTimeSettings({
searchValues: emptySearchValues,
storedValues: {
period: QueryPeriod['12mo'],
comparison: ComparisonMode.year_over_year,
match_day_of_week: false
},
defaultValues
})
).toEqual({
period: QueryPeriod['12mo'],
comparison: ComparisonMode.year_over_year,
match_day_of_week: false
})
})
it('uses values from search above all else, treats ComparisonMode.off as null', () => {
expect(
getDashboardTimeSettings({
searchValues: {
period: QueryPeriod['year'],
comparison: ComparisonMode.off,
match_day_of_week: true
},
storedValues: {
period: QueryPeriod['12mo'],
comparison: ComparisonMode.year_over_year,
match_day_of_week: false
},
defaultValues
})
).toEqual({
period: QueryPeriod['year'],
comparison: null,
match_day_of_week: true
})
})
})

View File

@ -0,0 +1,557 @@
/* @format */
import { useEffect } from 'react'
import {
clearedComparisonSearch,
clearedDateSearch,
DashboardQuery
} from './query'
import { PlausibleSite, useSiteContext } from './site-context'
import {
formatDateRange,
formatDay,
formatISO,
formatMonthYYYY,
formatYear,
isSameDate,
isSameMonth,
isThisMonth,
isThisYear,
isToday,
lastMonth,
nowForSite,
parseNaiveDate,
yesterday
} from './util/date'
import { AppNavigationTarget } from './navigation/use-app-navigate'
import { getDomainScopedStorageKey, getItem, setItem } from './util/storage'
import { useQueryContext } from './query-context'
export enum QueryPeriod {
'realtime' = 'realtime',
'day' = 'day',
'month' = 'month',
'7d' = '7d',
'30d' = '30d',
'6mo' = '6mo',
'12mo' = '12mo',
'year' = 'year',
'all' = 'all',
'custom' = 'custom'
}
export enum ComparisonMode {
off = 'off',
previous_period = 'previous_period',
year_over_year = 'year_over_year',
custom = 'custom'
}
export const COMPARISON_MODES = {
[ComparisonMode.off]: 'Disable comparison',
[ComparisonMode.previous_period]: 'Previous period',
[ComparisonMode.year_over_year]: 'Year over year',
[ComparisonMode.custom]: 'Custom period'
}
export enum ComparisonMatchMode {
MatchExactDate = 0,
MatchDayOfWeek = 1
}
export const COMPARISON_MATCH_MODE_LABELS = {
[ComparisonMatchMode.MatchDayOfWeek]: 'Match day of week',
[ComparisonMatchMode.MatchExactDate]: 'Match exact date'
}
export const DEFAULT_COMPARISON_MODE = ComparisonMode.previous_period
export const COMPARISON_DISABLED_PERIODS = [
QueryPeriod.realtime,
QueryPeriod.all
]
export const DEFAULT_COMPARISON_MATCH_MODE = ComparisonMatchMode.MatchDayOfWeek
export function getPeriodStorageKey(domain: string): string {
return getDomainScopedStorageKey('period', domain)
}
export function isValidPeriod(period: unknown): period is QueryPeriod {
return Object.values<unknown>(QueryPeriod).includes(period)
}
export function getStoredPeriod(
domain: string,
fallbackValue: QueryPeriod | null
) {
const item = getItem(getPeriodStorageKey(domain))
return isValidPeriod(item) ? item : fallbackValue
}
function storePeriod(domain: string, value: QueryPeriod) {
return setItem(getPeriodStorageKey(domain), value)
}
export const isValidComparison = (
comparison: unknown
): comparison is ComparisonMode =>
Object.values<unknown>(ComparisonMode).includes(comparison)
export const getMatchDayOfWeekStorageKey = (domain: string) =>
getDomainScopedStorageKey('comparison_match_day_of_week', domain)
export const isValidMatchDayOfWeek = (
matchDayOfWeek: unknown
): matchDayOfWeek is boolean =>
[true, false].includes(matchDayOfWeek as boolean)
export const storeMatchDayOfWeek = (domain: string, matchDayOfWeek: boolean) =>
setItem(getMatchDayOfWeekStorageKey(domain), matchDayOfWeek.toString())
export const getStoredMatchDayOfWeek = function (
domain: string,
fallbackValue: boolean | null
) {
const storedValue = getItem(getMatchDayOfWeekStorageKey(domain))
if (storedValue === 'true') {
return true
}
if (storedValue === 'false') {
return false
}
return fallbackValue
}
export const getComparisonModeStorageKey = (domain: string) =>
getDomainScopedStorageKey('comparison_mode', domain)
export const getStoredComparisonMode = function (
domain: string,
fallbackValue: ComparisonMode | null
): ComparisonMode | null {
const storedValue = getItem(getComparisonModeStorageKey(domain))
if (Object.values(ComparisonMode).includes(storedValue)) {
return storedValue
}
return fallbackValue
}
export const storeComparisonMode = function (
domain: string,
mode: ComparisonMode
) {
setItem(getComparisonModeStorageKey(domain), mode)
}
export const isComparisonEnabled = function (
mode?: ComparisonMode | null
): mode is Exclude<ComparisonMode, ComparisonMode.off> {
if (
[
ComparisonMode.custom,
ComparisonMode.previous_period,
ComparisonMode.year_over_year
].includes(mode as ComparisonMode)
) {
return true
}
return false
}
export const getSearchToToggleComparison = ({
site,
query
}: {
site: PlausibleSite
query: DashboardQuery
}): Required<AppNavigationTarget>['search'] => {
return (search) => {
if (isComparisonEnabled(query.comparison)) {
return {
...search,
...clearedComparisonSearch,
comparison: ComparisonMode.off,
keybindHint: 'X'
}
}
const storedMode = getStoredComparisonMode(site.domain, null)
const newMode = isComparisonEnabled(storedMode)
? storedMode
: DEFAULT_COMPARISON_MODE
return {
...search,
...clearedComparisonSearch,
comparison: newMode,
keybindHint: 'X'
}
}
}
export const getSearchToApplyCustomDates = ([selectionStart, selectionEnd]: [
Date,
Date
]): AppNavigationTarget['search'] => {
const [from, to] = [
parseNaiveDate(selectionStart),
parseNaiveDate(selectionEnd)
]
const singleDaySelected = from.isSame(to, 'day')
if (singleDaySelected) {
return (search) => ({
...search,
...clearedDateSearch,
period: QueryPeriod.day,
date: formatISO(from),
keybindHint: 'C'
})
}
return (search) => ({
...search,
...clearedDateSearch,
period: QueryPeriod.custom,
from: formatISO(from),
to: formatISO(to),
keybindHint: 'C'
})
}
export const getSearchToApplyCustomComparisonDates = ([
selectionStart,
selectionEnd
]: [Date, Date]): AppNavigationTarget['search'] => {
const [from, to] = [
parseNaiveDate(selectionStart),
parseNaiveDate(selectionEnd)
]
return (search) => ({
...search,
comparison: ComparisonMode.custom,
compare_from: formatISO(from),
compare_to: formatISO(to),
keybindHint: null
})
}
export type LinkItem = [
string[],
{
search: AppNavigationTarget['search']
isActive: (options: {
site: PlausibleSite
query: DashboardQuery
}) => boolean
onClick?: () => void
}
]
export const getDatePeriodGroups = (
site: PlausibleSite
): Array<Array<LinkItem>> => [
[
[
['Today', 'D'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.day,
date: formatISO(nowForSite(site)),
keybindHint: 'D'
}),
isActive: ({ query }) =>
query.period === QueryPeriod.day &&
isSameDate(query.date, nowForSite(site))
}
],
[
['Yesterday', 'E'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.day,
date: formatISO(yesterday(site)),
keybindHint: 'E'
}),
isActive: ({ query }) =>
query.period === QueryPeriod.day &&
isSameDate(query.date, yesterday(site))
}
],
[
['Realtime', 'R'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.realtime,
keybindHint: 'R'
}),
isActive: ({ query }) => query.period === QueryPeriod.realtime
}
]
],
[
[
['Last 7 Days', 'W'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod['7d'],
keybindHint: 'W'
}),
isActive: ({ query }) => query.period === QueryPeriod['7d']
}
],
[
['Last 30 Days', 'T'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod['30d'],
keybindHint: 'T'
}),
isActive: ({ query }) => query.period === QueryPeriod['30d']
}
]
],
[
[
['Month to Date', 'M'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.month,
keybindHint: 'M'
}),
isActive: ({ query }) =>
query.period === QueryPeriod.month &&
isSameMonth(query.date, nowForSite(site))
}
],
[
['Last Month'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.month,
date: formatISO(lastMonth(site)),
keybindHint: null
}),
isActive: ({ query }) =>
query.period === QueryPeriod.month &&
isSameMonth(query.date, lastMonth(site))
}
]
],
[
[
['Year to Date', 'Y'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.year,
keybindHint: 'Y'
}),
isActive: ({ query }) =>
query.period === QueryPeriod.year && isThisYear(site, query.date)
}
],
[
['Last 12 Months', 'L'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod['12mo'],
keybindHint: 'L'
}),
isActive: ({ query }) => query.period === QueryPeriod['12mo']
}
]
],
[
[
['All time', 'A'],
{
search: (s) => ({
...s,
...clearedDateSearch,
period: QueryPeriod.all,
keybindHint: 'A'
}),
isActive: ({ query }) => query.period === QueryPeriod.all
}
]
]
]
export const last6MonthsLinkItem: LinkItem = [
['Last 6 months', 'S'],
{
search: (s) => ({ ...s, period: QueryPeriod['6mo'], keybindHint: 'S' }),
isActive: ({ query }) => query.period === QueryPeriod['6mo']
}
]
export const getCompareLinkItem = ({
query,
site
}: {
query: DashboardQuery
site: PlausibleSite
}): LinkItem => [
[
isComparisonEnabled(query.comparison) ? 'Disable comparison' : 'Compare',
'X'
],
{
search: getSearchToToggleComparison({ site, query }),
isActive: () => false
}
]
export function useSaveTimePreferencesToStorage({
site,
period,
comparison,
match_day_of_week
}: {
site: PlausibleSite
period: unknown
comparison: unknown
match_day_of_week: unknown
}) {
useEffect(() => {
if (
isValidPeriod(period) &&
![QueryPeriod.custom, QueryPeriod.realtime].includes(period)
) {
storePeriod(site.domain, period)
}
if (isValidComparison(comparison) && comparison !== ComparisonMode.custom) {
storeComparisonMode(site.domain, comparison)
}
if (isValidMatchDayOfWeek(match_day_of_week)) {
storeMatchDayOfWeek(site.domain, match_day_of_week)
}
}, [period, comparison, match_day_of_week, site.domain])
}
export function getSavedTimePreferencesFromStorage({
site
}: {
site: PlausibleSite
}): {
period: null | QueryPeriod
comparison: null | ComparisonMode
match_day_of_week: boolean | null
} {
const stored = {
period: getStoredPeriod(site.domain, null),
comparison: getStoredComparisonMode(site.domain, null),
match_day_of_week: getStoredMatchDayOfWeek(site.domain, true)
}
return stored
}
export function getDashboardTimeSettings({
searchValues,
storedValues,
defaultValues
}: {
searchValues: Record<'period' | 'comparison' | 'match_day_of_week', unknown>
storedValues: ReturnType<typeof getSavedTimePreferencesFromStorage>
defaultValues: Pick<
DashboardQuery,
'period' | 'comparison' | 'match_day_of_week'
>
}): Pick<DashboardQuery, 'period' | 'comparison' | 'match_day_of_week'> {
let period: QueryPeriod
if (isValidPeriod(searchValues.period)) {
period = searchValues.period
} else {
period = isValidPeriod(storedValues.period)
? storedValues.period
: defaultValues.period
}
let comparison: ComparisonMode | null
if ([QueryPeriod.realtime, QueryPeriod.all].includes(period)) {
comparison = null
} else {
comparison = isValidComparison(searchValues.comparison)
? searchValues.comparison
: storedValues.comparison
if (!isComparisonEnabled(comparison)) {
comparison = null
}
}
const match_day_of_week = isValidMatchDayOfWeek(
searchValues.match_day_of_week
)
? (searchValues.match_day_of_week as boolean)
: isValidMatchDayOfWeek(storedValues.match_day_of_week)
? (storedValues.match_day_of_week as boolean)
: defaultValues.match_day_of_week
return {
period,
comparison,
match_day_of_week
}
}
export function DisplaySelectedPeriod() {
const { query } = useQueryContext()
const site = useSiteContext()
if (query.period === 'day') {
if (isToday(site, query.date)) {
return 'Today'
}
return formatDay(query.date)
}
if (query.period === '7d') {
return 'Last 7 days'
}
if (query.period === '30d') {
return 'Last 30 days'
}
if (query.period === 'month') {
if (isThisMonth(site, query.date)) {
return 'Month to Date'
}
return formatMonthYYYY(query.date)
}
if (query.period === '6mo') {
return 'Last 6 months'
}
if (query.period === '12mo') {
return 'Last 12 months'
}
if (query.period === 'year') {
if (isThisYear(site, query.date)) {
return 'Year to Date'
}
return formatYear(query.date)
}
if (query.period === 'all') {
return 'All time'
}
if (query.period === 'custom') {
return formatDateRange(site, query.from, query.to)
}
return 'Realtime'
}

View File

@ -1,199 +0,0 @@
import React, {useCallback} from 'react'
import { parseSearch, stringifySearch } from './util/url'
import { AppNavigationLink, useAppNavigate } from './navigation/use-app-navigate'
import { nowForSite } from './util/date'
import * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
import { getFiltersByKeyPrefix, parseLegacyFilter, parseLegacyPropsFilter } from './util/filters'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useQueryContext } from './query-context'
dayjs.extend(utc)
const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '6mo', '12mo', 'year', 'all', 'custom']
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)}
} else if (storage.getItem(periodKey)) {
period = storage.getItem(periodKey)
} else {
period = '30d'
}
let comparison = getValue('comparison') ?? getStoredComparisonMode(site.domain, null)
if (COMPARISON_DISABLED_PERIODS.includes(period) || !isComparisonEnabled(comparison)) comparison = null
let matchDayOfWeek = getValue('match_day_of_week') ?? getStoredMatchDayOfWeek(site.domain, true)
return {
period,
comparison,
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') || {}
}
}
export function addFilter(query, filter) {
return { ...query, filters: [...query.filters, filter] }
}
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 (newPartialSearchRecord.period && newPartialSearchRecord.period !== period) {
navigate({ search: (search) => ({ ...search, period: period }), replace: true })
}
// then push the new query to the history
navigate({ search: (search) => ({ ...search, ...newPartialSearchRecord }) })
}
const LEGACY_URL_PARAMETERS = {
'goal': null,
'source': null,
'utm_medium': null,
'utm_source': null,
'utm_campaign': null,
'utm_content': null,
'utm_term': null,
'referrer': null,
'screen': null,
'browser': null,
'browser_version': null,
'os': null,
'os_version': null,
'country': 'country_labels',
'region': 'region_labels',
'city': 'city_labels',
'page': null,
'hostname': null,
'entry_page': null,
'exit_page': null,
}
// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
// updates the filters and updates location
export function filtersBackwardsCompatibilityRedirect(windowLocation, windowHistory) {
const searchRecord = parseSearch(windowLocation.search)
const getValue = (k) => searchRecord[k];
// New filters are used - no need to do anything
if (getValue("filters")) {
return
}
const changedSearchRecordEntries = [];
let filters = []
let labels = {}
for (const [key, value] of Object.entries(searchRecord)) {
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
const filter = parseLegacyFilter(key, value)
filters.push(filter)
const labelsKey = LEGACY_URL_PARAMETERS[key]
if (labelsKey && getValue(labelsKey)) {
const clauses = filter[2]
const labelsValues = getValue(labelsKey).split('|').filter(label => !!label)
const newLabels = Object.fromEntries(clauses.map((clause, index) => [clause, labelsValues[index]]))
labels = Object.assign(labels, newLabels)
}
} else {
changedSearchRecordEntries.push([key, value])
}
}
if (getValue('props')) {
filters.push(...parseLegacyPropsFilter(getValue('props')))
}
if (filters.length > 0) {
changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
windowHistory.pushState({}, null, `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`)
}
}
// Returns a boolean indicating whether the given query includes a
// non-empty goal filterset containing a single, or multiple revenue
// goals with the same currency. Used to decide whether to render
// revenue metrics in a dashboard report or not.
export function revenueAvailable(query, site) {
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
const goalFilters = getFiltersByKeyPrefix(query, "goal")
return goalFilters.some(([_op, _key, clauses]) => {
return clauses.includes(rg.event_name)
})
})
const singleCurrency = revenueGoalsInFilter.every((rg) => {
return rg.currency === revenueGoalsInFilter[0].currency
})
return revenueGoalsInFilter.length > 0 && singleCurrency
}
export function QueryLink({ to, search, className, children, onClick }) {
const navigate = useAppNavigate();
const { query } = useQueryContext();
const handleClick = useCallback((e) => {
e.preventDefault()
navigateToQuery(navigate, query, search)
if (onClick) {
onClick(e)
}
}, [navigate, onClick, query, search])
return (
<AppNavigationLink
to={to}
search={(currentSearch) => ({...currentSearch, ...search})}
className={className}
onClick={handleClick}
>
{children}
</AppNavigationLink>
)
}
export function QueryButton({ search, disabled, className, children, onClick }) {
const navigate = useAppNavigate();
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={handleClick}
type="button"
disabled={disabled}
>
{children}
</button>
)
}

View File

@ -0,0 +1,272 @@
/** @format */
import { parseSearch, stringifySearch } from './util/url'
import {
nowForSite,
formatISO,
shiftDays,
shiftMonths,
isBefore,
parseUTCDate,
isAfter
} from './util/date'
import {
getFiltersByKeyPrefix,
parseLegacyFilter,
parseLegacyPropsFilter
} from './util/filters'
import { PlausibleSite } from './site-context'
import { ComparisonMode, QueryPeriod } from './query-time-periods'
import { AppNavigationTarget } from './navigation/use-app-navigate'
import { Dayjs } from 'dayjs'
export type FilterClause = string | number
export type FilterOperator = string
export type FilterKey = string
export type Filter = [FilterOperator, FilterKey, FilterClause[]]
/**
* Dictionary that holds a human readable value for ID-based filter clauses.
* Needed to show the human readable value in the Filters configuration screens.
* Does not go through the backend.
* For example,
* for filters `[["is", "city", [2761369]], ["is", "country", ["AT"]]]`,
* labels would be `{"2761369": "Vienna", "AT": "Austria"}`
* */
export type FilterClauseLabels = Record<string, unknown>
export const queryDefaultValue = {
period: '30d' as QueryPeriod,
comparison: null as ComparisonMode | null,
match_day_of_week: true,
date: null as Dayjs | null,
from: null as Dayjs | null,
to: null as Dayjs | null,
compare_from: null as Dayjs | null,
compare_to: null as Dayjs | null,
filters: [] as Filter[],
labels: {} as FilterClauseLabels,
with_imported: true
}
export type DashboardQuery = typeof queryDefaultValue
export function addFilter(
query: DashboardQuery,
filter: Filter
): DashboardQuery {
return { ...query, filters: [...query.filters, filter] }
}
const LEGACY_URL_PARAMETERS = {
goal: null,
source: null,
utm_medium: null,
utm_source: null,
utm_campaign: null,
utm_content: null,
utm_term: null,
referrer: null,
screen: null,
browser: null,
browser_version: null,
os: null,
os_version: null,
country: 'country_labels',
region: 'region_labels',
city: 'city_labels',
page: null,
hostname: null,
entry_page: null,
exit_page: null
}
// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
// updates the filters and updates location
export function filtersBackwardsCompatibilityRedirect(
windowLocation: Location,
windowHistory: History
) {
const searchRecord = parseSearch(windowLocation.search)
const getValue = (k: string) => searchRecord[k]
// New filters are used - no need to do anything
if (getValue('filters')) {
return
}
const changedSearchRecordEntries = []
const filters: DashboardQuery['filters'] = []
let labels: DashboardQuery['labels'] = {}
for (const [key, value] of Object.entries(searchRecord)) {
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
const filter = parseLegacyFilter(key, value) as Filter
filters.push(filter)
const labelsKey: string | null | undefined =
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
if (labelsKey && getValue(labelsKey)) {
const clauses = filter[2]
const labelsValues = (getValue(labelsKey) as string)
.split('|')
.filter((label) => !!label)
const newLabels = Object.fromEntries(
clauses.map((clause, index) => [clause, labelsValues[index]])
)
labels = Object.assign(labels, newLabels)
}
} else {
changedSearchRecordEntries.push([key, value])
}
}
if (getValue('props')) {
filters.push(...(parseLegacyPropsFilter(getValue('props')) as Filter[]))
}
if (filters.length > 0) {
changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
windowHistory.pushState(
{},
'',
`${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`
)
}
}
// Returns a boolean indicating whether the given query includes a
// non-empty goal filterset containing a single, or multiple revenue
// goals with the same currency. Used to decide whether to render
// revenue metrics in a dashboard report or not.
export function revenueAvailable(query: DashboardQuery, site: PlausibleSite) {
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
const goalFilters: Filter[] = getFiltersByKeyPrefix(query, 'goal')
return goalFilters.some(([_op, _key, clauses]) => {
return clauses.includes(rg.event_name)
})
})
const singleCurrency = revenueGoalsInFilter.every((rg) => {
return rg.currency === revenueGoalsInFilter[0].currency
})
return revenueGoalsInFilter.length > 0 && singleCurrency
}
export const clearedDateSearch = {
period: null,
from: null,
to: null,
date: null,
keybindHint: null
}
export const clearedComparisonSearch = {
comparison: null,
compare_from: null,
compare_to: null
}
export function isDateOnOrAfterStatsStartDate({
site,
date,
period
}: {
site: PlausibleSite
date: string
period: QueryPeriod
}) {
return !isBefore(parseUTCDate(date), parseUTCDate(site.statsBegin), period)
}
export function isDateBeforeOrOnCurrentDate({
site,
date,
period
}: {
site: PlausibleSite
date: string
period: QueryPeriod
}) {
const currentDate = nowForSite(site)
return !isAfter(parseUTCDate(date), currentDate, period)
}
export function getDateForShiftedPeriod({
site,
query,
direction
}: {
site: PlausibleSite
direction: -1 | 1
query: DashboardQuery
}) {
const isWithinRangeByDirection = {
'-1': isDateOnOrAfterStatsStartDate,
'1': isDateBeforeOrOnCurrentDate
}
const shiftByPeriod = {
[QueryPeriod.day]: { shift: shiftDays, amount: 1 },
[QueryPeriod.month]: { shift: shiftMonths, amount: 1 },
[QueryPeriod.year]: { shift: shiftMonths, amount: 12 }
} as const
const { shift, amount } =
shiftByPeriod[query.period as keyof typeof shiftByPeriod] ?? {}
if (shift) {
const date = shift(query.date, direction * amount)
if (
isWithinRangeByDirection[direction]({ site, date, period: query.period })
) {
return date
}
}
return null
}
function setQueryPeriodAndDate({
period,
date = null,
keybindHint = null
}: {
period: QueryPeriod
date?: null | string
keybindHint?: null | string
}): AppNavigationTarget['search'] {
return function (search) {
return {
...search,
...clearedDateSearch,
period,
date,
keybindHint
}
}
}
export function shiftQueryPeriod({
site,
query,
direction,
keybindHint
}: {
site: PlausibleSite
query: DashboardQuery
direction: -1 | 1
keybindHint?: null | string
}): AppNavigationTarget['search'] {
const date = getDateForShiftedPeriod({ site, query, direction })
if (date !== null) {
return setQueryPeriodAndDate({
period: query.period,
date: formatISO(date),
keybindHint
})
}
return (search) => search
}

View File

@ -25,6 +25,7 @@ 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 { DashboardKeybinds } from './dashboard-keybinds'
const queryClient = new QueryClient({
defaultOptions: {
@ -189,6 +190,7 @@ export function createAppRouter(site: PlausibleSite) {
...rootRoute,
errorElement: <RouteErrorElement />,
children: [
{ index: true, element: <DashboardKeybinds /> },
sourcesRoute,
utmMediumsRoute,
utmSourcesRoute,

View File

@ -1,6 +1,6 @@
import React, { Fragment, useCallback, useEffect, useRef } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import React, { Fragment, useCallback, useEffect } from 'react';
import classNames from 'classnames';
import * as storage from '../../util/storage';
import { isKeyPressed } from '../../keybinding';
@ -71,14 +71,13 @@ function storeInterval(period, domain, interval) {
storage.setItem(`interval__${period}__${domain}`, interval)
}
function subscribeKeybinding(element) {
// eslint-disable-next-line react-hooks/rules-of-hooks
function useIKeybinding(ref) {
const handleKeyPress = useCallback((event) => {
if (isKeyPressed(event, "i")) element.current?.click()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (isKeyPressed(event, "i")) {
ref.current?.click()
}
}, [ref])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
document.addEventListener('keydown', handleKeyPress)
return () => document.removeEventListener('keydown', handleKeyPress)
@ -99,16 +98,16 @@ export const getCurrentInterval = function(site, query) {
}
export function IntervalPicker({ onIntervalUpdate }) {
const menuElement = useRef(null)
const {query} = useQueryContext();
const site = useSiteContext();
if (query.period == 'realtime') return null
useIKeybinding(menuElement)
// eslint-disable-next-line react-hooks/rules-of-hooks
const menuElement = React.useRef(null)
if (query.period == 'realtime') return null
const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query)
subscribeKeybinding(menuElement)
function updateInterval(interval) {
storeInterval(query.period, site.domain, interval)

View File

@ -2,7 +2,6 @@ import React from 'react';
import { useAppNavigate } from '../../navigation/use-app-navigate';
import { useQueryContext } from '../../query-context';
import Chart from 'chart.js/auto';
import { navigateToQuery } from '../../query'
import GraphTooltip from './graph-tooltip'
import { buildDataSet, METRIC_LABELS, METRIC_FORMATTER } from './graph-util'
import dateFormatter from './date-formatter';
@ -226,9 +225,13 @@ 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.navigate, this.props.query, { period: 'month', date })
this.props.navigate({
search: ({ search }) => ({ ...search, period: 'month', date })
})
} else if (this.props.graphData.interval === 'day') {
navigateToQuery(this.props.navigate, this.props.query, { period: 'day', date })
this.props.navigate({
search: ({ search }) => ({ ...search, period: 'day', date })
})
}
}

View File

@ -10,7 +10,7 @@ import WithImportedSwitch from './with-imported-switch';
import SamplingNotice from './sampling-notice';
import FadeIn from '../../fade-in';
import * as url from '../../util/url';
import { isComparisonEnabled } from '../../comparison-input';
import { isComparisonEnabled } from '../../query-time-periods';
import LineGraphWithRouter from './line-graph';
import { useQueryContext } from '../../query-context';
import { useSiteContext } from '../../site-context';

View File

@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import classNames from 'classnames'
import * as api from '../../api'
import { navigateToQuery } from '../../query'
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
import { useAppNavigate } from '../../navigation/use-app-navigate'
import numberFormatter from '../../util/number-formatter'
@ -109,7 +108,7 @@ const WorldMap = ({
[country.code]: country.name
})
onCountrySelect()
navigateToQuery(navigate, query, { filters, labels })
navigate({ search: (search) => ({ ...search, filters, labels }) })
}
},
[navigate, query, dataByCountryCode, onCountrySelect]

View File

@ -1,6 +1,6 @@
import React from "react";
import { createPortal } from "react-dom";
import { shouldIgnoreKeypress } from '../../keybinding'
import { NavigateKeybind } from '../../keybinding'
import { rootRoute } from "../../router";
import { useAppNavigate } from "../../navigation/use-app-navigate";
@ -18,7 +18,6 @@ class Modal extends React.Component {
}
this.node = React.createRef()
this.handleClickOutside = this.handleClickOutside.bind(this)
this.handleKeyup = this.handleKeyup.bind(this)
this.handleResize = this.handleResize.bind(this)
}
@ -26,7 +25,6 @@ class Modal extends React.Component {
document.body.style.overflow = 'hidden';
document.body.style.height = '100vh';
document.addEventListener("mousedown", this.handleClickOutside);
document.addEventListener("keyup", this.handleKeyup);
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
}
@ -35,7 +33,6 @@ class Modal extends React.Component {
document.body.style.overflow = null;
document.body.style.height = null;
document.removeEventListener("mousedown", this.handleClickOutside);
document.removeEventListener("keyup", this.handleKeyup);
window.removeEventListener('resize', this.handleResize, false);
}
@ -47,12 +44,6 @@ class Modal extends React.Component {
this.close()
}
handleKeyup(e) {
if (!shouldIgnoreKeypress(e) && e.code === 'Escape') {
this.close()
}
}
handleResize() {
this.setState({ viewport: window.innerWidth });
}
@ -85,18 +76,22 @@ class Modal extends React.Component {
render() {
return createPortal(
<div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay">
<button className="modal__close"></button>
<div
ref={this.node}
className="modal__container dark:bg-gray-800"
style={this.getStyle()}
>
{this.props.children}
<>
<NavigateKeybind keyboardKey="Escape" type="keyup" navigateProps={{ path: rootRoute.path, search: (search) => search }} />
<div className="modal is-open" onClick={this.props.onClick}>
<div className="modal__overlay">
<button className="modal__close"></button>
<div
ref={this.node}
className="modal__container dark:bg-gray-800"
style={this.getStyle()}
>
{this.props.children}
</div>
</div>
</div>
</div>,
</>
,
document.getElementById("modal_root"),
);
}

View File

@ -72,7 +72,7 @@ export function nowForSite(site) {
}
export function yesterday(site) {
return nowForSite(site).subtract(1, 'day')
return shiftDays(nowForSite(site), -1)
}
export function lastMonth(site) {

View File

@ -0,0 +1,40 @@
/** @format */
import { RefObject, useCallback, useEffect } from 'react'
export function useOnClickOutside({
ref,
active,
handler
}: {
ref: RefObject<HTMLElement>
active: boolean
handler: () => void
}) {
const onClickOutsideClose = useCallback(
(e: MouseEvent) => {
const eventTarget = e.target as Element | null
if (ref.current && eventTarget && ref.current.contains(eventTarget)) {
return
}
handler()
},
[ref, handler]
)
useEffect(() => {
const register = () =>
document.addEventListener('mousedown', onClickOutsideClose)
const deregister = () =>
document.removeEventListener('mousedown', onClickOutsideClose)
if (active) {
register()
} else {
deregister()
}
return deregister
}, [active, onClickOutsideClose])
}

View File

@ -51,6 +51,7 @@
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-flatpickr": "^3.8.11",
"@types/topojson-client": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
@ -2394,6 +2395,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-flatpickr": {
"version": "3.8.11",
"resolved": "https://registry.npmjs.org/@types/react-flatpickr/-/react-flatpickr-3.8.11.tgz",
"integrity": "sha512-wXGyGRpUjiGknioxWzWJdNvF2XxKw5lAI7H64Iv7w4iL+1iT7QvAzrigz5FkW4lTg9IJOww6t7g21FzsrmRV6A==",
"dev": true,
"dependencies": {
"@types/react": "*",
"flatpickr": "^4.0.6"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",

View File

@ -54,6 +54,7 @@
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-flatpickr": "^3.8.11",
"@types/topojson-client": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",