mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Adds types to dashboard query, makes keybinds route specific
This commit is contained in:
parent
9c71161eab
commit
8c5988e39a
@ -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
|
123
assets/js/dashboard/components/dropdown.tsx
Normal file
123
assets/js/dashboard/components/dropdown.tsx
Normal 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>
|
||||
)
|
29
assets/js/dashboard/dashboard-keybinds.tsx
Normal file
29
assets/js/dashboard/dashboard-keybinds.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
64
assets/js/dashboard/date-range-calendar.tsx
Normal file
64
assets/js/dashboard/date-range-calendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
447
assets/js/dashboard/datepicker.tsx
Normal file
447
assets/js/dashboard/datepicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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} />
|
||||
|
@ -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)
|
||||
}
|
109
assets/js/dashboard/keybinding.tsx
Normal file
109
assets/js/dashboard/keybinding.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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"`
|
||||
*/
|
||||
|
@ -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>
|
||||
};
|
151
assets/js/dashboard/query-context.tsx
Normal file
151
assets/js/dashboard/query-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 />, {
|
||||
|
92
assets/js/dashboard/query-time-periods.test.ts
Normal file
92
assets/js/dashboard/query-time-periods.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
557
assets/js/dashboard/query-time-periods.ts
Normal file
557
assets/js/dashboard/query-time-periods.ts
Normal 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'
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
272
assets/js/dashboard/query.ts
Normal file
272
assets/js/dashboard/query.ts
Normal 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
|
||||
}
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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]
|
||||
|
@ -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"),
|
||||
);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
40
assets/js/dashboard/util/use-on-click-outside.ts
Normal file
40
assets/js/dashboard/util/use-on-click-outside.ts
Normal 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])
|
||||
}
|
11
assets/package-lock.json
generated
11
assets/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user