From 8c5988e39a7aabc5ddb525a57bfaa8a085321ea9 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 21 Aug 2024 15:49:43 +0300 Subject: [PATCH] Adds types to dashboard query, makes keybinds route specific --- assets/js/dashboard/comparison-input.js | 222 ------- assets/js/dashboard/components/dropdown.tsx | 123 ++++ assets/js/dashboard/dashboard-keybinds.tsx | 29 + assets/js/dashboard/date-range-calendar.tsx | 64 ++ assets/js/dashboard/datepicker.js | 456 -------------- assets/js/dashboard/datepicker.tsx | 447 ++++++++++++++ assets/js/dashboard/filters.js | 40 +- assets/js/dashboard/historical.js | 6 +- assets/js/dashboard/keybinding.js | 34 -- assets/js/dashboard/keybinding.tsx | 109 ++++ .../dashboard/navigation/use-app-navigate.tsx | 2 +- assets/js/dashboard/query-context.js | 41 -- assets/js/dashboard/query-context.tsx | 151 +++++ assets/js/dashboard/query-dates.test.tsx | 14 +- .../js/dashboard/query-time-periods.test.ts | 92 +++ assets/js/dashboard/query-time-periods.ts | 557 ++++++++++++++++++ assets/js/dashboard/query.js | 199 ------- assets/js/dashboard/query.ts | 272 +++++++++ assets/js/dashboard/router.tsx | 2 + .../dashboard/stats/graph/interval-picker.js | 21 +- assets/js/dashboard/stats/graph/line-graph.js | 9 +- .../js/dashboard/stats/graph/visitor-graph.js | 2 +- assets/js/dashboard/stats/locations/map.tsx | 3 +- assets/js/dashboard/stats/modals/modal.js | 35 +- assets/js/dashboard/util/date.js | 2 +- .../js/dashboard/util/use-on-click-outside.ts | 40 ++ assets/package-lock.json | 11 + assets/package.json | 1 + 28 files changed, 1956 insertions(+), 1028 deletions(-) delete mode 100644 assets/js/dashboard/comparison-input.js create mode 100644 assets/js/dashboard/components/dropdown.tsx create mode 100644 assets/js/dashboard/dashboard-keybinds.tsx create mode 100644 assets/js/dashboard/date-range-calendar.tsx delete mode 100644 assets/js/dashboard/datepicker.js create mode 100644 assets/js/dashboard/datepicker.tsx delete mode 100644 assets/js/dashboard/keybinding.js create mode 100644 assets/js/dashboard/keybinding.tsx delete mode 100644 assets/js/dashboard/query-context.js create mode 100644 assets/js/dashboard/query-context.tsx create mode 100644 assets/js/dashboard/query-time-periods.test.ts create mode 100644 assets/js/dashboard/query-time-periods.ts delete mode 100644 assets/js/dashboard/query.js create mode 100644 assets/js/dashboard/query.ts create mode 100644 assets/js/dashboard/util/use-on-click-outside.ts diff --git a/assets/js/dashboard/comparison-input.js b/assets/js/dashboard/comparison-input.js deleted file mode 100644 index 30c125c85..000000000 --- a/assets/js/dashboard/comparison-input.js +++ /dev/null @@ -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 - } - - const disabled = isCurrentlySelected && value !== "custom" - - return ( - - {render} - - ) -} - -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 <> - click(true)}> - {({ active }) => ( - - )} - - - click(false)}> - {({ active }) => ( - - )} - - -} - -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 ( - <> - vs. -
-
- - - {buildLabel(site, query)} - - - - {Object.keys(COMPARISON_MODES).map((key) => ComparisonModeOption({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode }))} - {query.comparison !== "custom" && -
- -
} -
-
- - {uiMode == "datepicker" && -
- -
} -
-
-
- - ) -} - -export default ComparisonInput diff --git a/assets/js/dashboard/components/dropdown.tsx b/assets/js/dashboard/components/dropdown.tsx new file mode 100644 index 000000000..308720f52 --- /dev/null +++ b/assets/js/dashboard/components/dropdown.tsx @@ -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 ( +
+ + {children} +
+ ) +}) + +export const DropdownMenuWrapper = forwardRef< + HTMLDivElement, + { innerContainerClassName?: string; children: ReactNode } & DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement + > +>(({ children, className, innerContainerClassName, ...props }, ref) => { + return ( +
+ + {children} + +
+ ) +}) + +export const DropdownLinkGroup = ({ + className, + children, + ...props +}: DetailedHTMLProps, HTMLDivElement>) => ( +
+ {children} +
+) + +export const DropdownNavigationLink = ({ + children, + active, + className, + ...props +}: AppNavigationTarget & { + active?: boolean + children: ReactNode + className?: string + onClick?: () => void +}) => ( + + {children} + +) diff --git a/assets/js/dashboard/dashboard-keybinds.tsx b/assets/js/dashboard/dashboard-keybinds.tsx new file mode 100644 index 000000000..f40b244df --- /dev/null +++ b/assets/js/dashboard/dashboard-keybinds.tsx @@ -0,0 +1,29 @@ +/* @format */ +import React from 'react' +import { NavigateKeybind } from './keybinding' + +const ClearFiltersKeybind = () => ( + + search.filters || search.labels + ? { + ...search, + filters: null, + labels: null, + keybindHint: 'Escape' + } + : search + }} + /> +) + +export function DashboardKeybinds() { + return ( + <> + + + ) +} diff --git a/assets/js/dashboard/date-range-calendar.tsx b/assets/js/dashboard/date-range-calendar.tsx new file mode 100644 index 000000000..76aeca4a7 --- /dev/null +++ b/assets/js/dashboard/date-range-calendar.tsx @@ -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(null) + + useEffect(() => { + const calendar = calendarRef.current + if (calendar) { + calendar.flatpickr.open() + } + + return () => { + calendar?.flatpickr?.destroy() + } + }, []) + + return ( +
+ { + if (selectionStart && selectionEnd) { + if (onCloseWithSelection) { + onCloseWithSelection([selectionStart, selectionEnd]) + } + } else { + if (onCloseWithNoSelection) { + onCloseWithNoSelection() + } + } + } + : undefined + } + className="invisible" + /> +
+ ) +} diff --git a/assets/js/dashboard/datepicker.js b/assets/js/dashboard/datepicker.js deleted file mode 100644 index abfd9960a..000000000 --- a/assets/js/dashboard/datepicker.js +++ /dev/null @@ -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 ( - {children} - ) -} - -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 ( -
- - - - - - - - - - -
- ); -} - -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 ( - 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 ? ({opts.keybindHint}) : null} - - ); - } - - function renderDropDownContent() { - if (mode === "menu") { - return ( -
-
-
- {renderLink("day", "Today", { keybindHint: 'D', date: nowForSite(site) })} - {renderLink("day", "Yesterday", { keybindHint: 'E', date: yesterday(site) })} - {renderLink("realtime", "Realtime", { keybindHint: 'R' })} -
-
- {renderLink("7d", "Last 7 Days", { keybindHint: 'W' })} - {renderLink("30d", "Last 30 Days", { keybindHint: 'T' })} -
-
- {renderLink('month', 'Month to Date', { keybindHint: 'M' })} - {renderLink('month', 'Last Month', { date: lastMonth(site) })} -
-
- {renderLink("year", "Year to Date", { keybindHint: 'Y' })} - {renderLink("12mo", "Last 12 months", { keybindHint: 'L' })} -
-
- {renderLink("all", "All time", { keybindHint: 'A' })} - 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 - C - -
- {!COMPARISON_DISABLED_PERIODS.includes(query.period) && -
- { - 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'} - X - -
} -
-
- ); - } if (mode === "calendar") { - return ( -
- -
- ) - } - } - - function renderPicker() { - return ( -
- - - - {renderDropDownContent()} - -
- ); - } - - return ( -
- - {renderPicker()} -
- ) -} - -export default DatePicker diff --git a/assets/js/dashboard/datepicker.tsx b/assets/js/dashboard/datepicker.tsx new file mode 100644 index 000000000..829c0e24b --- /dev/null +++ b/assets/js/dashboard/datepicker.tsx @@ -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 ( + + ) +} + +function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { + return ( + + {direction === 'left' && } + {direction === 'right' && } + + ) +} + +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 ( +
+ search + } + > + + + search + } + > + + +
+ ) +} + +function ComparisonMenu({ + toggleCompareMenuCalendar +}: { + toggleCompareMenuCalendar: () => void +}) { + const { query } = useQueryContext() + + return ( + + + {[ + ComparisonMode.off, + ComparisonMode.previous_period, + ComparisonMode.year_over_year + ].map((comparisonMode) => ( + ({ + ...search, + ...clearedComparisonSearch, + comparison: comparisonMode + })} + > + {COMPARISON_MODES[comparisonMode]} + + ))} + s} + onClick={toggleCompareMenuCalendar} + > + {COMPARISON_MODES[ComparisonMode.custom]} + + + {query.comparison !== ComparisonMode.custom && ( + + ({ ...s, match_day_of_week: true })} + > + {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchDayOfWeek]} + + ({ ...s, match_day_of_week: false })} + > + {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchExactDate]} + + + )} + + ) +} + +function QueryPeriodsMenu({ + groups, + closeMenu +}: { + groups: LinkItem[][] + closeMenu: () => void +}) { + const site = useSiteContext() + const { query } = useQueryContext() + return ( + + {groups.map((group, index) => ( + + {group.map( + ([[label, keyboardKey], { search, isActive, onClick }]) => ( + + {label} + {!!keyboardKey && {keyboardKey}} + + ) + )} + + ))} + + ) +} + +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(null) + const compareDropdownRef = useRef(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 ( +
+ + } + ref={dropdownRef} + onClick={toggleDateMenu} + dropdownContainerProps={{ + ['aria-controls']: 'datemenu', + ['aria-expanded']: menuVisible === 'datemenu' + }} + > + {menuVisible === 'datemenu' && ( + + )} + {menuVisible === 'datemenu-calendar' && ( + + navigate({ search: getSearchToApplyCustomDates(selection) }) + } + minDate={site.statsBegin} + defaultDates={ + query.to && query.from + ? [formatISO(query.from), formatISO(query.to)] + : undefined + } + /> + )} + + {isComparisonEnabled(query.comparison) && ( + <> +
+ vs. +
+ + {menuVisible === 'compare-menu' && ( + + )} + {menuVisible === 'compare-menu-calendar' && ( + + navigate({ + search: getSearchToApplyCustomComparisonDates(selection) + }) + } + minDate={site.statsBegin} + defaultDates={ + query.compare_from && query.compare_to + ? [ + formatISO(query.compare_from), + formatISO(query.compare_to) + ] + : undefined + } + /> + )} + + + )} + {!!dashboardRouteMatch && ( + <> + + + {groups + .concat([[last6MonthsLinkItem]]) + .flatMap((group) => + group + .filter(([[_name, keyboardKey]]) => !!keyboardKey) + .map(([[_name, keyboardKey], { search, onClick, isActive }]) => + onClick || isActive({ site, query }) ? ( + + ) : ( + + ) + ) + )} + + )} +
+ ) +} diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index 1d9c19de2..4c640ddf7 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -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 }) { {query.filters.map((filter, index) => )} -
clearAllFilters(navigate, query)}> +
clearAllFilters(navigate)}> Clear All Filters
@@ -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) } diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js index 6c01cce8c..76d0ce49b 100644 --- a/assets/js/dashboard/historical.js +++ b/assets/js/dashboard/historical.js @@ -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 }) {
- - + diff --git a/assets/js/dashboard/keybinding.js b/assets/js/dashboard/keybinding.js deleted file mode 100644 index 003eb573d..000000000 --- a/assets/js/dashboard/keybinding.js +++ /dev/null @@ -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 , but the user pressed or , 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

and - * the user types , 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) -} diff --git a/assets/js/dashboard/keybinding.tsx b/assets/js/dashboard/keybinding.tsx new file mode 100644 index 000000000..d05a1f892 --- /dev/null +++ b/assets/js/dashboard/keybinding.tsx @@ -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 , but the user pressed or , 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

and + * the user types , 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 +} + +export function KeybindHint({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx index 88b152f25..b30d888ec 100644 --- a/assets/js/dashboard/navigation/use-app-navigate.tsx +++ b/assets/js/dashboard/navigation/use-app-navigate.tsx @@ -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"` */ diff --git a/assets/js/dashboard/query-context.js b/assets/js/dashboard/query-context.js deleted file mode 100644 index 52c9c4507..000000000 --- a/assets/js/dashboard/query-context.js +++ /dev/null @@ -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 {children} -}; diff --git a/assets/js/dashboard/query-context.tsx b/assets/js/dashboard/query-context.tsx new file mode 100644 index 000000000..25056ebbf --- /dev/null +++ b/assets/js/dashboard/query-context.tsx @@ -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, + 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 ( + + {children} + + ) +} diff --git a/assets/js/dashboard/query-dates.test.tsx b/assets/js/dashboard/query-dates.test.tsx index 76564cb34..c83d41e26 100644 --- a/assets/js/dashboard/query-dates.test.tsx +++ b/assets/js/dashboard/query-dates.test.tsx @@ -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(, { diff --git a/assets/js/dashboard/query-time-periods.test.ts b/assets/js/dashboard/query-time-periods.test.ts new file mode 100644 index 000000000..b2361092d --- /dev/null +++ b/assets/js/dashboard/query-time-periods.test.ts @@ -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 + }) + }) +}) diff --git a/assets/js/dashboard/query-time-periods.ts b/assets/js/dashboard/query-time-periods.ts new file mode 100644 index 000000000..59f94ec8e --- /dev/null +++ b/assets/js/dashboard/query-time-periods.ts @@ -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(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(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 { + 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['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> => [ + [ + [ + ['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 + defaultValues: Pick< + DashboardQuery, + 'period' | 'comparison' | 'match_day_of_week' + > +}): Pick { + 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' +} diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js deleted file mode 100644 index 879630e91..000000000 --- a/assets/js/dashboard/query.js +++ /dev/null @@ -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 ( - ({...currentSearch, ...search})} - className={className} - onClick={handleClick} - > - {children} - - ) -} - -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 ( - - ) -} - diff --git a/assets/js/dashboard/query.ts b/assets/js/dashboard/query.ts new file mode 100644 index 000000000..5ca08d0fd --- /dev/null +++ b/assets/js/dashboard/query.ts @@ -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 + +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 +} diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index 7fd0eafc8..c3cfdb5de 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -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: , children: [ + { index: true, element: }, sourcesRoute, utmMediumsRoute, utmSourcesRoute, diff --git a/assets/js/dashboard/stats/graph/interval-picker.js b/assets/js/dashboard/stats/graph/interval-picker.js index c201cfbf1..bce3dfa3b 100644 --- a/assets/js/dashboard/stats/graph/interval-picker.js +++ b/assets/js/dashboard/stats/graph/interval-picker.js @@ -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) diff --git a/assets/js/dashboard/stats/graph/line-graph.js b/assets/js/dashboard/stats/graph/line-graph.js index f4f93dfc4..069423cd5 100644 --- a/assets/js/dashboard/stats/graph/line-graph.js +++ b/assets/js/dashboard/stats/graph/line-graph.js @@ -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 }) + }) } } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index 5516d8f2e..b9950b429 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -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'; diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 9cd017b8f..ba1df544c 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -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] diff --git a/assets/js/dashboard/stats/modals/modal.js b/assets/js/dashboard/stats/modals/modal.js index 70b64fc3e..2b7114cb8 100644 --- a/assets/js/dashboard/stats/modals/modal.js +++ b/assets/js/dashboard/stats/modals/modal.js @@ -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( -

-
- -
- {this.props.children} + <> + search }} /> +
+
+ +
+ {this.props.children} +
-
, + +, document.getElementById("modal_root"), ); } diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js index 9247a59b5..e0519406c 100644 --- a/assets/js/dashboard/util/date.js +++ b/assets/js/dashboard/util/date.js @@ -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) { diff --git a/assets/js/dashboard/util/use-on-click-outside.ts b/assets/js/dashboard/util/use-on-click-outside.ts new file mode 100644 index 000000000..dd2cd02f6 --- /dev/null +++ b/assets/js/dashboard/util/use-on-click-outside.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { RefObject, useCallback, useEffect } from 'react' + +export function useOnClickOutside({ + ref, + active, + handler +}: { + ref: RefObject + 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]) +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 0e327b470..22eb422a0 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -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", diff --git a/assets/package.json b/assets/package.json index 558f793d5..f83ba88c2 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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",