diff --git a/assets/css/app.css b/assets/css/app.css index 7d294a127901..0e1401d092fc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5,7 +5,7 @@ @import './modal.css'; @import './loader.css'; @import './tooltip.css'; -@import './flatpickr.css'; +@import './flatpickr-colors.css'; @import './chartjs.css'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; @@ -246,11 +246,6 @@ blockquote { transition: opacity 100ms ease-in; } -.flatpickr-calendar.static.open { - right: 2px; - top: 12px; -} - .datamaps-subunit { cursor: pointer; } diff --git a/assets/css/flatpickr.css b/assets/css/flatpickr-colors.css similarity index 88% rename from assets/css/flatpickr.css rename to assets/css/flatpickr-colors.css index 3b15f7718d55..44dbdf34c974 100644 --- a/assets/css/flatpickr.css +++ b/assets/css/flatpickr-colors.css @@ -1,28 +1,6 @@ /* @format */ /* stylelint-disable media-feature-range-notation */ /* stylelint-disable selector-class-pattern */ -.flatpickr-calendar::before, -.flatpickr-calendar::after { - right: 22px !important; -} - -.flatpickr-wrapper { - right: 35% !important; -} - -@media (max-width: 768px) { - .flatpickr-wrapper { - right: 50% !important; - } -} - -@media (max-width: 768px) { - .flatpickr-wrapper { - position: absolute !important; - right: 0 !important; - left: 0 !important; - } -} /* Because Flatpickr offers zero support for dynamic theming on its own (outside of third-party plugins) */ .dark .flatpickr-calendar { diff --git a/assets/js/dashboard/components/dropdown.tsx b/assets/js/dashboard/components/dropdown.tsx deleted file mode 100644 index c3397211f192..000000000000 --- a/assets/js/dashboard/components/dropdown.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** @format */ - -import React, { - 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, - { - variant?: 'ghost' | 'button' - withDropdownIndicator?: boolean - className?: string - currentOption: ReactNode - children: ReactNode - onClick: () => void - dropdownContainerProps: DetailedHTMLProps< - HTMLAttributes, - HTMLButtonElement - > - } ->( - ( - { - className, - currentOption, - withDropdownIndicator, - children, - onClick, - dropdownContainerProps, - ...props - }, - ref - ) => { - const { variant } = { variant: 'button', ...props } - const sharedButtonClass = - 'flex items-center rounded text-sm leading-tight px-2 py-2 h-9' - - const buttonClass = { - ghost: - 'text-gray-500 hover:text-gray-800 hover:bg-gray-200 dark:hover:text-gray-200 dark:hover:bg-gray-900', - button: - 'w-full justify-between bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900' - }[variant] - - return ( -
- - {children} -
- ) - } -) - -export const DropdownMenuWrapper = forwardRef< - HTMLDivElement, - { innerContainerClassName?: string; children: ReactNode } & DetailedHTMLProps< - HTMLAttributes, - HTMLDivElement - > ->(({ children, className, innerContainerClassName, ...props }, ref) => { - return ( -
- - {children} - -
- ) -}) - -export const DropdownLinkGroup = ({ - className, - children, - ...props -}: DetailedHTMLProps, HTMLDivElement>) => ( -
- {children} -
-) - -export const DropdownNavigationLink = ({ - children, - active, - className, - ...props -}: AppNavigationTarget & { - active?: boolean - children: ReactNode - className?: string - onClick?: () => void -}) => ( - - {children} - -) diff --git a/assets/js/dashboard/components/filter-operator-selector.js b/assets/js/dashboard/components/filter-operator-selector.js index a45c560e38fa..f9289d85d93b 100644 --- a/assets/js/dashboard/components/filter-operator-selector.js +++ b/assets/js/dashboard/components/filter-operator-selector.js @@ -1,6 +1,6 @@ /** @format */ -import React, { Fragment } from 'react' +import React, { Fragment, useRef } from 'react' import { FILTER_OPERATIONS, @@ -12,9 +12,11 @@ import { import { Menu, Transition } from '@headlessui/react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' +import { BlurMenuButtonOnEscape } from '../keybinding' export default function FilterOperatorSelector(props) { const filterName = props.forFilter + const buttonRef = useRef() function renderTypeItem(operation, shouldDisplay) { return ( @@ -46,8 +48,12 @@ export default function FilterOperatorSelector(props) { {({ open }) => ( <> +
- + {FILTER_OPERATIONS_DISPLAY_NAMES[props.selectedType]} !isFocused]} - target={searchBoxRef.current} + targetRef={searchBoxRef} /> isFocused]} - target={document} + targetRef="document" /> setIsFocused(false)} diff --git a/assets/js/dashboard/date-range-calendar.tsx b/assets/js/dashboard/date-range-calendar.tsx deleted file mode 100644 index 76aeca4a762d..000000000000 --- a/assets/js/dashboard/date-range-calendar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* @format */ -import React, { useEffect, useRef } from 'react' -import DatePicker from 'react-flatpickr' - -export function DateRangeCalendar({ - minDate, - maxDate, - defaultDates, - onCloseWithNoSelection, - onCloseWithSelection -}: { - minDate?: string - maxDate?: string - defaultDates?: [string, string] - onCloseWithNoSelection?: () => void - onCloseWithSelection?: ([selectionStart, selectionEnd]: [Date, Date]) => void -}) { - const calendarRef = useRef(null) - - useEffect(() => { - const calendar = calendarRef.current - if (calendar) { - calendar.flatpickr.open() - } - - return () => { - calendar?.flatpickr?.destroy() - } - }, []) - - return ( -
- { - if (selectionStart && selectionEnd) { - if (onCloseWithSelection) { - onCloseWithSelection([selectionStart, selectionEnd]) - } - } else { - if (onCloseWithNoSelection) { - onCloseWithNoSelection() - } - } - } - : undefined - } - className="invisible" - /> -
- ) -} diff --git a/assets/js/dashboard/datepicker.tsx b/assets/js/dashboard/datepicker.tsx deleted file mode 100644 index 913089100fef..000000000000 --- a/assets/js/dashboard/datepicker.tsx +++ /dev/null @@ -1,468 +0,0 @@ -/* @format */ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { formatDateRange, formatISO, nowForSite } from './util/date' -import { - shiftQueryPeriod, - getDateForShiftedPeriod, - clearedComparisonSearch -} from './query' -import classNames from 'classnames' -import { useQueryContext } from './query-context' -import { useSiteContext } from './site-context' -import { - isModifierPressed, - isTyping, - Keybind, - KeybindHint, - NavigateKeybind -} from './keybinding' -import { - AppNavigationLink, - useAppNavigate -} from './navigation/use-app-navigate' -import { DateRangeCalendar } from './date-range-calendar' -import { - COMPARISON_DISABLED_PERIODS, - COMPARISON_MODES, - ComparisonMode, - DisplaySelectedPeriod, - getCompareLinkItem, - isComparisonEnabled, - getSearchToApplyCustomComparisonDates, - getSearchToApplyCustomDates, - QueryPeriod, - last6MonthsLinkItem, - getDatePeriodGroups, - LinkItem, - COMPARISON_MATCH_MODE_LABELS, - ComparisonMatchMode -} from './query-time-periods' -import { useOnClickOutside } from './util/use-on-click-outside' -import { - DropdownLinkGroup, - DropdownMenuWrapper, - DropdownNavigationLink, - ToggleDropdownButton -} from './components/dropdown' -import { useMatch } from 'react-router-dom' -import { rootRoute } from './router' - -const ArrowKeybind = ({ - keyboardKey -}: { - keyboardKey: 'ArrowLeft' | 'ArrowRight' -}) => { - const site = useSiteContext() - const { query } = useQueryContext() - - const search = useMemo( - () => - shiftQueryPeriod({ - query, - site, - direction: ({ ArrowLeft: -1, ArrowRight: 1 } as const)[keyboardKey], - keybindHint: keyboardKey - }), - [site, query, keyboardKey] - ) - - return ( - - ) -} - -function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { - return ( - - {direction === 'left' && } - {direction === 'right' && } - - ) -} - -function MovePeriodArrows() { - const periodsWithArrows = [ - QueryPeriod.year, - QueryPeriod.month, - QueryPeriod.day - ] - const { query } = useQueryContext() - const site = useSiteContext() - if (!periodsWithArrows.includes(query.period)) { - return null - } - - const canGoBack = - getDateForShiftedPeriod({ site, query, direction: -1 }) !== null - const canGoForward = - getDateForShiftedPeriod({ site, query, direction: 1 }) !== null - - const isComparing = isComparisonEnabled(query.comparison) - - const sharedClass = 'flex items-center px-1 sm:px-2 dark:text-gray-100' - const enabledClass = 'hover:bg-gray-100 dark:hover:bg-gray-900' - const disabledClass = 'bg-gray-300 dark:bg-gray-950 cursor-not-allowed' - - const containerClass = classNames( - 'rounded shadow bg-white mr-2 sm:mr-4 cursor-pointer dark:bg-gray-800', - { - 'hidden md:flex': isComparing, - flex: !isComparing - } - ) - - return ( -
- search - } - > - - - search - } - > - - -
- ) -} - -function ComparisonMenu({ - toggleCompareMenuCalendar -}: { - toggleCompareMenuCalendar: () => void -}) { - const { query } = useQueryContext() - - return ( - - - {[ - ComparisonMode.off, - ComparisonMode.previous_period, - ComparisonMode.year_over_year - ].map((comparisonMode) => ( - ({ - ...search, - ...clearedComparisonSearch, - comparison: comparisonMode - })} - > - {COMPARISON_MODES[comparisonMode]} - - ))} - s} - onClick={toggleCompareMenuCalendar} - > - {COMPARISON_MODES[ComparisonMode.custom]} - - - {query.comparison !== ComparisonMode.custom && ( - - ({ ...s, match_day_of_week: true })} - > - {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchDayOfWeek]} - - ({ ...s, match_day_of_week: false })} - > - {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchExactDate]} - - - )} - - ) -} - -function QueryPeriodsMenu({ - groups, - closeMenu -}: { - groups: LinkItem[][] - closeMenu: () => void -}) { - const site = useSiteContext() - const { query } = useQueryContext() - return ( - - {groups.map((group, index) => ( - - {group.map( - ([[label, keyboardKey], { search, isActive, onClick }]) => ( - - {label} - {!!keyboardKey && {keyboardKey}} - - ) - )} - - ))} - - ) -} - -export default function QueryPeriodPicker() { - const site = useSiteContext() - const { query } = useQueryContext() - const navigate = useAppNavigate() - const [menuVisible, setMenuVisible] = useState< - | 'datemenu' - | 'datemenu-calendar' - | 'compare-menu' - | 'compare-menu-calendar' - | null - >(null) - const dropdownRef = useRef(null) - const compareDropdownRef = useRef(null) - - const dashboardRouteMatch = useMatch(rootRoute.path) - - const closeMenu = useCallback(() => { - setMenuVisible(null) - }, []) - - const toggleDateMenu = useCallback(() => { - setMenuVisible((prevState) => - prevState === 'datemenu' ? null : 'datemenu' - ) - }, []) - - const toggleCompareMenu = useCallback(() => { - setMenuVisible((prevState) => - prevState === 'compare-menu' ? null : 'compare-menu' - ) - }, []) - - const toggleDateMenuCalendar = useCallback(() => { - setMenuVisible((prevState) => - prevState === 'datemenu-calendar' ? null : 'datemenu-calendar' - ) - }, []) - - const toggleCompareMenuCalendar = useCallback(() => { - setMenuVisible((prevState) => - prevState === 'compare-menu-calendar' ? null : 'compare-menu-calendar' - ) - }, []) - - const customRangeLink: LinkItem = useMemo( - () => [ - ['Custom Range', 'C'], - { - search: (s) => s, - isActive: ({ query }) => query.period === QueryPeriod.custom, - onClick: toggleDateMenuCalendar - } - ], - [toggleDateMenuCalendar] - ) - const compareLink: LinkItem = useMemo( - () => getCompareLinkItem({ site, query }), - [site, query] - ) - - const datePeriodGroups = 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 add Compare link as another group to the very end - return groups.concat([[compareLink]]) - }, [site, query, customRangeLink, compareLink]) - - useOnClickOutside({ - ref: dropdownRef, - active: menuVisible === 'datemenu', - handler: closeMenu - }) - - useOnClickOutside({ - ref: compareDropdownRef, - active: menuVisible === 'compare-menu', - handler: closeMenu - }) - - useEffect(() => { - closeMenu() - }, [closeMenu, query]) - - return ( -
- - } - ref={dropdownRef} - onClick={toggleDateMenu} - dropdownContainerProps={{ - ['aria-controls']: 'datemenu', - ['aria-expanded']: menuVisible === 'datemenu' - }} - > - {menuVisible === 'datemenu' && ( - - )} - {menuVisible === 'datemenu-calendar' && ( - - navigate({ search: getSearchToApplyCustomDates(selection) }) - } - minDate={site.statsBegin} - maxDate={formatISO(nowForSite(site))} - defaultDates={ - query.to && query.from - ? [formatISO(query.from), formatISO(query.to)] - : undefined - } - /> - )} - - {isComparisonEnabled(query.comparison) && ( - <> -
- vs. -
- - {menuVisible === 'compare-menu' && ( - - )} - {menuVisible === 'compare-menu-calendar' && ( - - navigate({ - search: getSearchToApplyCustomComparisonDates(selection) - }) - } - minDate={site.statsBegin} - maxDate={formatISO(nowForSite(site))} - defaultDates={ - query.compare_from && query.compare_to - ? [ - formatISO(query.compare_from), - formatISO(query.compare_to) - ] - : undefined - } - /> - )} - - - )} - {!!dashboardRouteMatch && ( - <> - - - {datePeriodGroups - .concat([[last6MonthsLinkItem]]) - .flatMap((group) => - group - .filter(([[_name, keyboardKey]]) => !!keyboardKey) - .map(([[_name, keyboardKey], { search, onClick, isActive }]) => - onClick || isActive({ site, query }) ? ( - - ) : ( - - ) - ) - )} - - )} -
- ) -} diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index a0765e076ddc..31d5d56edd4e 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -230,11 +230,11 @@ function Filters() { function renderDropDown() { return ( - + {({ open }) => ( <>
- + {renderDropdownButton()}
diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index afa3fe356f11..a815451243e2 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -8,7 +8,6 @@ import Locations from './stats/locations' import Devices from './stats/devices' import { TopBar } from './nav-menu/top-bar' import Behaviours from './stats/behaviours' -import { FiltersBar } from './nav-menu/filters-bar' import { useQueryContext } from './query-context' import { isRealTimeDashboard } from './util/filters' @@ -61,10 +60,7 @@ function Dashboard() { return (
- } - /> + void shouldIgnoreWhen?: Array<(event: KeyboardEvent) => boolean> - target?: Document | HTMLElement | null + targetRef?: 'document' | RefObject | null } function useKeybind({ @@ -73,7 +73,7 @@ function useKeybind({ type, handler, shouldIgnoreWhen = [], - target + targetRef }: KeybindOptions) { const wrappedHandler = useCallback( (event: KeyboardEvent) => { @@ -85,22 +85,23 @@ function useKeybind({ ) as EventListener useEffect(() => { + const element = targetRef === 'document' ? document : targetRef?.current const registerKeybind = (t: HTMLElement | Document) => t.addEventListener(type, wrappedHandler) const deregisterKeybind = (t: HTMLElement | Document) => t.removeEventListener(type, wrappedHandler) - if (target) { - registerKeybind(target) + if (element) { + registerKeybind(element) } return () => { - if (target) { - deregisterKeybind(target) + if (element) { + deregisterKeybind(element) } } - }, [target, type, wrappedHandler]) + }, [targetRef, type, wrappedHandler]) } export function Keybind(opts: KeybindOptions) { @@ -129,7 +130,7 @@ export function NavigateKeybind({ type={type} handler={handler} shouldIgnoreWhen={[isModifierPressed, isTyping]} - target={document} + targetRef="document" /> ) } @@ -141,3 +142,32 @@ export function KeybindHint({ children }: { children: ReactNode }) { ) } + +/** + * Rendering this component captures the Escape key on targetRef.current, + * blurring the element on Escape, and stopping the event from propagating. + * Needed to prevent other Escape handlers that may exist from running. + */ +export function BlurMenuButtonOnEscape({ + targetRef: targetRef +}: { + targetRef: RefObject +}) { + return ( + { + const t = event.target as HTMLElement | null + if (typeof t?.blur === 'function') { + if (t === targetRef.current) { + t.blur() + event.stopPropagation() + } + } + }} + targetRef={targetRef} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + /> + ) +} diff --git a/assets/js/dashboard/nav-menu/filter-menu.tsx b/assets/js/dashboard/nav-menu/filter-menu.tsx index 63bc7cd67b79..6b88f6605a89 100644 --- a/assets/js/dashboard/nav-menu/filter-menu.tsx +++ b/assets/js/dashboard/nav-menu/filter-menu.tsx @@ -1,83 +1,119 @@ /** @format */ -import React, { useMemo, useRef, useState } from 'react' -import { - DropdownLinkGroup, - DropdownMenuWrapper, - DropdownNavigationLink, - ToggleDropdownButton -} from '../components/dropdown' -import { MagnifyingGlassIcon } from '@heroicons/react/20/solid' +import React, { useMemo, useRef } from 'react' import { FILTER_MODAL_TO_FILTER_GROUP, formatFilterGroup } from '../util/filters' import { PlausibleSite, useSiteContext } from '../site-context' import { filterRoute } from '../router' -import { useOnClickOutside } from '../util/use-on-click-outside' +import { PlusIcon } from '@heroicons/react/20/solid' +import { Popover, Transition } from '@headlessui/react' +import { popover } from '../components/popover' +import classNames from 'classnames' +import { AppNavigationLink } from '../navigation/use-app-navigate' +import { BlurMenuButtonOnEscape } from '../keybinding' export function getFilterListItems({ propsAvailable -}: Pick): { - modalKey: string - label: string -}[] { - const allKeys = Object.keys(FILTER_MODAL_TO_FILTER_GROUP) as Array< - keyof typeof FILTER_MODAL_TO_FILTER_GROUP - > - const keysToOmit: Array = - propsAvailable ? [] : ['props'] - return allKeys - .filter((k) => !keysToOmit.includes(k)) - .map((modalKey) => ({ modalKey, label: formatFilterGroup(modalKey) })) +}: Pick): Array< + Array<{ + title: string + modals: Array + }> +> { + return [ + [ + { + title: 'URL', + modals: ['page', 'hostname'] + }, + { + title: 'Acquisition', + modals: ['source', 'utm'] + } + ], + [ + { + title: 'Device', + modals: ['location', 'screen', 'browser', 'os'] + }, + { + title: 'Behaviour', + modals: ['goal', !!propsAvailable && 'props'] + } + ] + ] } -export const FilterMenu = () => { - const dropdownRef = useRef(null) - const [opened, setOpened] = useState(false) +const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => { const site = useSiteContext() - const filterListItems = useMemo(() => getFilterListItems(site), [site]) - - useOnClickOutside({ - ref: dropdownRef, - active: opened, - handler: () => setOpened(false) - }) - + const columns = useMemo(() => getFilterListItems(site), [site]) + const buttonRef = useRef(null) return ( - setOpened((opened) => !opened)} - currentOption={ - - - Filter + <> + + + + + Add filter - } - > - {opened && ( - - - {filterListItems.map(({ modalKey, label }) => ( - search} - > - {label} - - ))} - - - )} - + + + + {columns.map((filterGroups, index) => ( +
+ {filterGroups.map(({ title, modals }) => ( +
+
+ {title} +
+ {modals + .filter((m) => !!m) + .map((modalKey) => ( + closeDropdown()} + key={modalKey} + path={filterRoute.path} + params={{ field: modalKey }} + search={(s) => s} + > + {formatFilterGroup(modalKey)} + + ))} +
+ ))} +
+ ))} +
+
+ ) } + +export const FilterMenu = () => ( + + {({ close }) => } + +) diff --git a/assets/js/dashboard/nav-menu/filter-pill.tsx b/assets/js/dashboard/nav-menu/filter-pill.tsx index 20ac38e654b7..690477a44d6b 100644 --- a/assets/js/dashboard/nav-menu/filter-pill.tsx +++ b/assets/js/dashboard/nav-menu/filter-pill.tsx @@ -1,24 +1,41 @@ /** @format */ import React, { ReactNode } from 'react' -import { AppNavigationLink } from '../navigation/use-app-navigate' -import { filterRoute } from '../router' +import { + AppNavigationLink, + AppNavigationTarget +} from '../navigation/use-app-navigate' import { XMarkIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' +export type FilterPillProps = { + className?: string + plainText: string + interactive: + | { + onRemoveClick?: () => void + navigationTarget: AppNavigationTarget + } + | false + children: ReactNode + actions?: ReactNode +} + +const PillContent = ({ children }: { children?: ReactNode }) => ( + + {children} + +) + export function FilterPill({ className, plainText, children, - modalToOpen, - onRemoveClick -}: { - className?: string - plainText: string - modalToOpen: string - children: ReactNode - onRemoveClick: () => void -}) { + interactive, + actions +}: FilterPillProps) { + const contentClassName = 'flex w-full h-full items-center py-2 pl-3 last:pr-3' + return (
- search} - > - - {children} - - - + {interactive ? ( + <> + + {children} + + {!!interactive.onRemoveClick && ( + + )} + {actions} + + ) : ( + <> +
+ {children} +
+ {actions} + + )}
) } diff --git a/assets/js/dashboard/nav-menu/filter-pills-list.tsx b/assets/js/dashboard/nav-menu/filter-pills-list.tsx index a6c21758e4cc..d501b290d0d6 100644 --- a/assets/js/dashboard/nav-menu/filter-pills-list.tsx +++ b/assets/js/dashboard/nav-menu/filter-pills-list.tsx @@ -2,7 +2,7 @@ import React, { DetailedHTMLProps, HTMLAttributes } from 'react' import { useQueryContext } from '../query-context' -import { FilterPill } from './filter-pill' +import { FilterPill, FilterPillProps } from './filter-pill' import { cleanLabels, EVENT_PROPS_PREFIX, @@ -11,28 +11,40 @@ import { import { styledFilterText, plainFilterText } from '../util/filter-text' import { useAppNavigate } from '../navigation/use-app-navigate' import classNames from 'classnames' +import { filterRoute } from '../router' -export const PILL_X_GAP = 16 -export const PILL_Y_GAP = 8 +export const PILL_X_GAP_PX = 16 +export const PILL_Y_GAP_PX = 8 -/** Restricts output to slice of DashboardQuery['filters'], or makes the output outside the slice invisible */ -type Slice = { +type SliceStartEnd = { /** The beginning index of the specified portion of the array. If start is undefined, then the slice begins at index 0. */ start?: number /** The end index of the specified portion of the array. This is exclusive of the element at the index 'end'. If end is undefined, then the slice extends to the end of the array. */ end?: number - /** Determines if it renders the elements outside the slice with invisible or doesn't render the elements at all */ - type: 'hide-outside' | 'no-render-outside' } -type FilterPillsProps = { +type InvisibleOutsideSlice = { + type: 'invisible-outside' +} & SliceStartEnd + +type NoRenderOutsideSlice = { + type: 'no-render-outside' +} & SliceStartEnd + +type AppliedFilterPillsListProps = Omit< + FilterPillsListProps, + 'slice' | 'pillProps' | 'pills' +> & { slice?: InvisibleOutsideSlice | NoRenderOutsideSlice } + +type FilterPillsListProps = { direction: 'horizontal' | 'vertical' - slice?: Slice -} & DetailedHTMLProps, HTMLDivElement> +} & DetailedHTMLProps, HTMLDivElement> & { + pills: FilterPillProps[] + } -export const FilterPillsList = React.forwardRef< +export const AppliedFilterPillsList = React.forwardRef< HTMLDivElement, - FilterPillsProps + AppliedFilterPillsListProps >(({ className, style, slice, direction }, ref) => { const { query } = useQueryContext() const navigate = useAppNavigate() @@ -46,50 +58,82 @@ export const FilterPillsList = React.forwardRef< slice?.type === 'no-render-outside' ? (slice.start ?? 0) : 0 const isInvisible = (index: number) => { - return slice?.type === 'hide-outside' + return slice?.type === 'invisible-outside' ? index < (slice.start ?? 0) || index > (slice.end ?? query.filters.length) - 1 : false } return ( -
- {renderableFilters.map((filter, index) => ( - + ({ + className: classNames(isInvisible(index) && 'invisible'), + plainText: plainFilterText(query, filter), + children: styledFilterText(query, filter), + interactive: { + navigationTarget: { + path: filterRoute.path, + search: (s) => s, + params: { + field: + FILTER_GROUP_TO_MODAL_TYPE[ + filter[1].startsWith(EVENT_PROPS_PREFIX) ? 'props' : filter[1] + ] + } + }, + onRemoveClick: () => { + const newFilters = query.filters.filter( + (_, i) => i !== index + indexAdjustment + ) + navigate({ search: (search) => ({ ...search, - filters: query.filters.filter( - (_, i) => i !== index + indexAdjustment - ), - labels: cleanLabels(query.filters, query.labels) + filters: newFilters, + labels: cleanLabels(newFilters, query.labels) }) }) } - > - {styledFilterText(query, filter)} - - ))} + } + }))} + className={className} + style={style} + ref={ref} + direction={direction} + /> + ) +}) + +export const FilterPillsList = React.forwardRef< + HTMLDivElement, + FilterPillsListProps +>(({ className, style, direction, pills }, ref) => { + // this padding allows pill dropshadows to be visible + // even when overflow:hidden is given to pill parent container + // box-content guarantees width given as style to apply to available space + const innerClassName = 'p-1 box-content' + // this hides the padding of the inner component to ease placement + const wrapperClassName = '-m-1' + return ( +
+
+ {pills.map((options, index) => ( + + ))} +
) }) diff --git a/assets/js/dashboard/nav-menu/filters-bar.test.tsx b/assets/js/dashboard/nav-menu/filters-bar.test.tsx index 13c99786a310..f9bae645f71b 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.test.tsx @@ -14,11 +14,9 @@ beforeAll(() => { const mockResizeObserver = jest.fn( (handleEntries) => ({ - observe: jest - .fn() - .mockImplementation((entry) => - handleEntries([entry], null as unknown as ResizeObserver) - ), + observe: jest.fn().mockImplementation((entry) => { + handleEntries([entry], null as unknown as ResizeObserver) + }), unobserve: jest.fn(), disconnect: jest.fn() }) as unknown as ResizeObserver @@ -37,15 +35,39 @@ test('user can see expected filters and clear them one by one or all together', } const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}` - render(, { - wrapper: (props) => ( - - ) - }) + render( + + ({ + getBoundingClientRect: jest.fn().mockReturnValue(600) + }) as unknown as HTMLElement + ), + leftSection: jest.fn( + () => + ({ + getBoundingClientRect: jest.fn().mockReturnValue(200) + }) as unknown as HTMLElement + ), + rightSection: jest.fn( + () => + ({ + getBoundingClientRect: jest.fn().mockReturnValue(300) + }) as unknown as HTMLElement + ) + }} + />, + { + wrapper: (props) => ( + + ) + } + ) const queryFilterPills = () => screen.queryAllByRole('link', { hidden: false, name: /.* is .*/i }) @@ -56,7 +78,7 @@ test('user can see expected filters and clear them one by one or all together', await userEvent.click( screen.getByRole('button', { hidden: false, - name: 'Show rest of the filters' + name: 'See more' }) ) @@ -90,10 +112,8 @@ describe(`${handleVisibility.name}`, () => { const setVisibility = jest.fn() const input = { setVisibility, - topBarWidth: 1000, - actionsWidth: 100, - seeMorePresent: false, - seeMoreWidth: 50, + leftoverWidth: 1000, + seeMoreWidth: 100, pillWidths: [200, 200, 200, 200], pillGap: 25 } @@ -105,9 +125,7 @@ describe(`${handleVisibility.name}`, () => { }) handleVisibility({ - ...input, - seeMorePresent: true, - actionsWidth: input.actionsWidth + input.seeMoreWidth + ...input }) expect(setVisibility).toHaveBeenCalledTimes(2) expect(setVisibility).toHaveBeenLastCalledWith({ @@ -115,7 +133,7 @@ describe(`${handleVisibility.name}`, () => { visibleCount: 4 }) - handleVisibility({ ...input, topBarWidth: 999 }) + handleVisibility({ ...input, leftoverWidth: 999 }) expect(setVisibility).toHaveBeenCalledTimes(3) expect(setVisibility).toHaveBeenLastCalledWith({ width: 675, @@ -123,19 +141,34 @@ describe(`${handleVisibility.name}`, () => { }) }) - it('can shrink to 0 width', () => { + it('handles 1 filter correctly', () => { const setVisibility = jest.fn() const input = { setVisibility, - topBarWidth: 300, - actionsWidth: 100, - seeMorePresent: true, + leftoverWidth: 300, seeMoreWidth: 50, pillWidths: [250], pillGap: 25 } handleVisibility(input) expect(setVisibility).toHaveBeenCalledTimes(1) + expect(setVisibility).toHaveBeenLastCalledWith({ + width: 275, + visibleCount: 1 + }) + }) + + it('handles 2 filters correctly, shrinking to 0 width', () => { + const setVisibility = jest.fn() + const input = { + setVisibility, + leftoverWidth: 300, + seeMoreWidth: 50, + pillWidths: [250, 200], + pillGap: 25 + } + handleVisibility(input) + expect(setVisibility).toHaveBeenCalledTimes(1) expect(setVisibility).toHaveBeenLastCalledWith({ width: 0, visibleCount: 0 diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx index e0f48e342611..27155605af04 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.tsx @@ -1,38 +1,39 @@ /** @format */ -import { EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/react/20/solid' +import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid' import classNames from 'classnames' -import React, { useRef, useState, useLayoutEffect, useEffect } from 'react' -import { AppNavigationLink } from '../navigation/use-app-navigate' -import { useOnClickOutside } from '../util/use-on-click-outside' -import { - DropdownMenuWrapper, - ToggleDropdownButton -} from '../components/dropdown' -import { FilterPillsList, PILL_X_GAP } from './filter-pills-list' +import React, { useRef, useState, useLayoutEffect } from 'react' +import { AppliedFilterPillsList, PILL_X_GAP_PX } from './filter-pills-list' import { useQueryContext } from '../query-context' - -const SEE_MORE_GAP_PX = 16 +import { AppNavigationLink } from '../navigation/use-app-navigate' +import { Popover, Transition } from '@headlessui/react' +import { popover } from '../components/popover' +import { BlurMenuButtonOnEscape } from '../keybinding' + +// Component structure is +// `..[ filter (x) ]..[ filter (x) ]..[ three dot menu ]..` +// where `..` represents an ideally equal length. +// The following calculations guarantee that. +const BUFFER_RIGHT_PX = 16 - PILL_X_GAP_PX +const BUFFER_LEFT_PX = 16 const SEE_MORE_WIDTH_PX = 36 +const SEE_MORE_RIGHT_MARGIN_PX = PILL_X_GAP_PX +const SEE_MORE_LEFT_MARGIN_PX = 0 export const handleVisibility = ({ setVisibility, - topBarWidth, - actionsWidth, - seeMorePresent, + leftoverWidth, seeMoreWidth, pillWidths, pillGap }: { setVisibility: (v: VisibilityState) => void - topBarWidth: number | null - actionsWidth: number | null + leftoverWidth: number | null pillWidths: (number | null)[] | null - seeMorePresent: boolean seeMoreWidth: number pillGap: number }): void => { - if (topBarWidth === null || actionsWidth === null || pillWidths === null) { + if (leftoverWidth === null || pillWidths === null) { return } @@ -52,22 +53,14 @@ export const handleVisibility = ({ return { visibleCount, lastValidWidth } } - const fits = fitToWidth(topBarWidth - actionsWidth) + const fits = fitToWidth(leftoverWidth) - // Check if possible to fit one more if "See more" is removed - if (seeMorePresent && fits.visibleCount === pillWidths.length - 1) { - const maybeFitsMore = fitToWidth(topBarWidth - actionsWidth + seeMoreWidth) - if (maybeFitsMore.visibleCount === pillWidths.length) { - return setVisibility({ - width: maybeFitsMore.lastValidWidth, - visibleCount: maybeFitsMore.visibleCount - }) - } - } + const seeMoreWillBePresent = + fits.visibleCount < pillWidths.length || pillWidths.length > 1 // Check if the appearance of "See more" would cause overflow - if (!seeMorePresent && fits.visibleCount < pillWidths.length) { - const maybeFitsLess = fitToWidth(topBarWidth - actionsWidth - seeMoreWidth) + if (seeMoreWillBePresent) { + const maybeFitsLess = fitToWidth(leftoverWidth - seeMoreWidth) if (maybeFitsLess.visibleCount < fits.visibleCount) { return setVisibility({ width: maybeFitsLess.lastValidWidth, @@ -82,137 +75,173 @@ export const handleVisibility = ({ }) } -const getElementWidthOrNull = (element: T | null) => - element === null ? null : element.getBoundingClientRect().width +const getElementWidthOrNull = < + T extends Pick +>( + element: T | null +) => (element === null ? null : element.getBoundingClientRect().width) type VisibilityState = { width: number visibleCount: number } -export const FiltersBar = () => { +type ElementAccessor = ( + filtersBarElement: HTMLElement | null +) => HTMLElement | null | undefined + +/** + * The accessors are paths to other elements that FiltersBar needs to measure: + * they depend on the structure of the parent and are thus passed as props. + * Passing these with refs would be more reactive, but the main layout effect + * didn't trigger then as expected. + */ +interface FiltersBarProps { + accessors: { + topBar: ElementAccessor + leftSection: ElementAccessor + rightSection: ElementAccessor + } +} + +export const FiltersBar = ({ accessors }: FiltersBarProps) => { const containerRef = useRef(null) const pillsRef = useRef(null) - const actionsRef = useRef(null) - const seeMoreRef = useRef(null) const [visibility, setVisibility] = useState(null) const { query } = useQueryContext() - - const [opened, setOpened] = useState(false) - - useEffect(() => { - if (visibility?.visibleCount === query.filters.length) { - setOpened(false) - } - }, [visibility?.visibleCount, query.filters.length]) - - useOnClickOutside({ - ref: seeMoreRef, - active: opened, - handler: () => setOpened(false) - }) + const seeMoreRef = useRef(null) useLayoutEffect(() => { - const resizeObserver = new ResizeObserver((_entries) => { + const topBar = accessors.topBar(containerRef.current) + const leftSection = accessors.leftSection(containerRef.current) + const rightSection = accessors.rightSection(containerRef.current) + + const resizeObserver = new ResizeObserver(() => { const pillWidths = pillsRef.current ? Array.from(pillsRef.current.children).map((el) => - getElementWidthOrNull(el as HTMLElement) + getElementWidthOrNull(el) ) : null handleVisibility({ setVisibility, pillWidths, - pillGap: PILL_X_GAP, - topBarWidth: getElementWidthOrNull(containerRef.current), - actionsWidth: getElementWidthOrNull(actionsRef.current), - seeMorePresent: !!seeMoreRef.current, - seeMoreWidth: SEE_MORE_WIDTH_PX + SEE_MORE_GAP_PX + pillGap: PILL_X_GAP_PX, + leftoverWidth: + topBar && leftSection && rightSection + ? getElementWidthOrNull(topBar)! - + getElementWidthOrNull(leftSection)! - + getElementWidthOrNull(rightSection)! - + BUFFER_LEFT_PX - + BUFFER_RIGHT_PX + : null, + seeMoreWidth: + SEE_MORE_LEFT_MARGIN_PX + SEE_MORE_WIDTH_PX + SEE_MORE_RIGHT_MARGIN_PX }) }) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) + if (containerRef.current && topBar) { + resizeObserver.observe(topBar) } return () => { resizeObserver.disconnect() } - }, [query.filters]) + }, [accessors, query.filters]) if (!query.filters.length) { - return null + // functions as spacer between elements.leftSection and elements.rightSection + return
} + const canClear = query.filters.length > 1 + return (
- -
- {visibility !== null && - visibility.visibleCount !== query.filters.length && ( - + +
+ {visibility !== null && + (query.filters.length !== visibility.visibleCount || canClear) && ( + + + setOpened((opened) => !opened)} - currentOption={ - - } > - {opened && typeof visibility.visibleCount === 'number' ? ( - - + + + + {query.filters.length !== visibility.visibleCount && ( + - - ) : null} - - )} - -
+ )} + {canClear && } + + + + )}
) } -export const ClearAction = () => ( +const ClearAction = () => ( ({ ...search, filters: null, labels: null })} > - + Clear all filters ) diff --git a/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx new file mode 100644 index 000000000000..1cdb7143b2f8 --- /dev/null +++ b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx @@ -0,0 +1,179 @@ +/** @format */ + +import React, { useRef } from 'react' +import { clearedComparisonSearch } from '../../query' +import classNames from 'classnames' +import { useQueryContext } from '../../query-context' +import { useSiteContext } from '../../site-context' +import { BlurMenuButtonOnEscape } from '../../keybinding' +import { + AppNavigationLink, + useAppNavigate +} from '../../navigation/use-app-navigate' +import { + COMPARISON_MODES, + ComparisonMode, + isComparisonEnabled, + COMPARISON_MATCH_MODE_LABELS, + ComparisonMatchMode, + getCurrentComparisonPeriodDisplayName, + getSearchToApplyCustomComparisonDates +} from '../../query-time-periods' +import { Popover, Transition } from '@headlessui/react' +import { popover } from '../../components/popover' +import { + datemenuButtonClassName, + DateMenuChevron, + PopoverMenuProps, + linkClassName, + MenuSeparator, + CalendarPanel, + hiddenCalendarButtonClassName +} from './shared-menu-items' +import { DateRangeCalendar } from './date-range-calendar' +import { formatISO, nowForSite } from '../../util/date' + +export const ComparisonPeriodMenuItems = ({ + closeDropdown, + toggleCalendar +}: { + closeDropdown: () => void + toggleCalendar: () => void +}) => { + const { query } = useQueryContext() + + if (!isComparisonEnabled(query.comparison)) { + return null + } + + return ( + + + {[ + ComparisonMode.off, + ComparisonMode.previous_period, + ComparisonMode.year_over_year + ].map((comparisonMode) => ( + ({ + ...search, + ...clearedComparisonSearch, + comparison: comparisonMode + })} + onClick={closeDropdown} + > + {COMPARISON_MODES[comparisonMode]} + + ))} + s} + onClick={toggleCalendar} + > + {COMPARISON_MODES[ComparisonMode.custom]} + + {query.comparison !== ComparisonMode.custom && ( + <> + + ({ ...s, match_day_of_week: true })} + onClick={closeDropdown} + > + {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchDayOfWeek]} + + ({ ...s, match_day_of_week: false })} + onClick={closeDropdown} + > + {COMPARISON_MATCH_MODE_LABELS[ComparisonMatchMode.MatchExactDate]} + + + )} + + + ) +} + +export const ComparisonPeriodMenu = ({ + calendarButtonRef, + closeDropdown +}: PopoverMenuProps) => { + const site = useSiteContext() + const { query } = useQueryContext() + + const buttonRef = useRef(null) + const toggleCalendar = () => { + if (typeof calendarButtonRef.current?.click === 'function') { + calendarButtonRef.current.click() + } + } + + return ( + <> + + + + {getCurrentComparisonPeriodDisplayName({ site, query })} + + + + + + ) +} + +export const ComparisonCalendarMenu = ({ + closeDropdown, + calendarButtonRef +}: PopoverMenuProps) => { + const site = useSiteContext() + const navigate = useAppNavigate() + const { query } = useQueryContext() + + return ( + <> + + + + { + navigate({ + search: getSearchToApplyCustomComparisonDates(selection) + }) + closeDropdown() + }} + minDate={site.statsBegin} + maxDate={formatISO(nowForSite(site))} + defaultDates={ + query.compare_from && query.compare_to + ? [formatISO(query.compare_from), formatISO(query.compare_to)] + : undefined + } + /> + + + ) +} diff --git a/assets/js/dashboard/date-range-calendar.test.tsx b/assets/js/dashboard/nav-menu/query-periods/date-range-calendar.test.tsx similarity index 99% rename from assets/js/dashboard/date-range-calendar.test.tsx rename to assets/js/dashboard/nav-menu/query-periods/date-range-calendar.test.tsx index 0511d963279d..bb33ed3e2cf8 100644 --- a/assets/js/dashboard/date-range-calendar.test.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/date-range-calendar.test.tsx @@ -12,6 +12,7 @@ test('renders with default dates in view, respects max and min dates', async () render( void + onCloseWithSelection?: ([selectionStart, selectionEnd]: [Date, Date]) => void +} + +export function DateRangeCalendar({ + id, + minDate, + maxDate, + defaultDates, + onCloseWithNoSelection, + onCloseWithSelection +}: DateRangeCalendarProps) { + const hideInputFieldClassName = '!invisible !h-0 !w-0 !p-0 !m-0 !border-0' + const calendarRef = useRef(null) + useLayoutEffect(() => { + // on Safari, this removes little arrow pointing to (hidden) input, + // which didn't appear with other browsers + calendarRef.current?.flatpickr?.calendarContainer?.classList.remove( + 'arrowTop', + 'arrowBottom', + 'arrowLeft', + 'arrowRight' + ) + }, []) + return ( + { + if (selectionStart && selectionEnd) { + if (onCloseWithSelection) { + onCloseWithSelection([selectionStart, selectionEnd]) + } + } else { + if (onCloseWithNoSelection) { + onCloseWithNoSelection() + } + } + } + : undefined + } + /> + ) +} diff --git a/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx b/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx new file mode 100644 index 000000000000..3fda3fe0138c --- /dev/null +++ b/assets/js/dashboard/nav-menu/query-periods/move-period-arrows.tsx @@ -0,0 +1,130 @@ +/* @format */ +import React, { useMemo } from 'react' +import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query' +import classNames from 'classnames' +import { useQueryContext } from '../../query-context' +import { useSiteContext } from '../../site-context' +import { NavigateKeybind } from '../../keybinding' +import { AppNavigationLink } from '../../navigation/use-app-navigate' +import { QueryPeriod } from '../../query-time-periods' +import { useMatch } from 'react-router-dom' +import { rootRoute } from '../../router' + +const ArrowKeybind = ({ + keyboardKey +}: { + keyboardKey: 'ArrowLeft' | 'ArrowRight' +}) => { + const site = useSiteContext() + const { query } = useQueryContext() + + const search = useMemo( + () => + shiftQueryPeriod({ + query, + site, + direction: ({ ArrowLeft: -1, ArrowRight: 1 } as const)[keyboardKey], + keybindHint: keyboardKey + }), + [site, query, keyboardKey] + ) + + return ( + + ) +} + +function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { + return ( + + {direction === 'left' && } + {direction === 'right' && } + + ) +} + +export function MovePeriodArrows({ className }: { className?: string }) { + const periodsWithArrows = [ + QueryPeriod.year, + QueryPeriod.month, + QueryPeriod.day + ] + const { query } = useQueryContext() + const site = useSiteContext() + const dashboardRouteMatch = useMatch(rootRoute.path) + + if (!periodsWithArrows.includes(query.period)) { + return null + } + + const canGoBack = + getDateForShiftedPeriod({ site, query, direction: -1 }) !== null + const canGoForward = + getDateForShiftedPeriod({ site, query, direction: 1 }) !== null + + 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' + + return ( +
+ search + } + > + + + search + } + > + + + {!!dashboardRouteMatch && } + {!!dashboardRouteMatch && } +
+ ) +} diff --git a/assets/js/dashboard/query-dates.test.tsx b/assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx similarity index 91% rename from assets/js/dashboard/query-dates.test.tsx rename to assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx index 234f96693b2d..ee0581259cf1 100644 --- a/assets/js/dashboard/query-dates.test.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/query-dates.test.tsx @@ -3,18 +3,18 @@ import React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import DatePicker from './datepicker' -import { TestContextProviders } from '../../test-utils/app-context-providers' -import { stringifySearch } from './util/url-search-params' +import { TestContextProviders } from '../../../../test-utils/app-context-providers' +import { stringifySearch } from '../../util/url-search-params' import { useNavigate } from 'react-router-dom' -import { getRouterBasepath } from './router' +import { getRouterBasepath } from '../../router' +import { QueryPeriodsPicker } from './query-periods-picker' const domain = 'picking-query-dates.test' const periodStorageKey = `period__${domain}` test('if no period is stored, loads with default value of "Last 30 days", all expected options are present', async () => { expect(localStorage.getItem(periodStorageKey)).toBe(null) - render(, { + render(, { wrapper: (props) => ( ) @@ -42,7 +42,7 @@ test('if no period is stored, loads with default value of "Last 30 days", all ex }) test('user can select a new period and its value is stored', async () => { - render(, { + render(, { wrapper: (props) => ( ) @@ -58,7 +58,7 @@ test('user can select a new period and its value is stored', async () => { test('period "all" is respected, and Compare option is not present for it in menu', async () => { localStorage.setItem(periodStorageKey, 'all') - render(, { + render(, { wrapper: (props) => ( ) @@ -78,7 +78,7 @@ test.each([ async (searchRecord, buttonText) => { const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}` - render(, { + render(, { wrapper: (props) => ( { const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}` - render(, { + render(, { wrapper: (props) => ( , { + render(, { wrapper: (props) => ( } render( <> - + , { diff --git a/assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx new file mode 100644 index 000000000000..3f188438ce1e --- /dev/null +++ b/assets/js/dashboard/nav-menu/query-periods/query-period-menu.tsx @@ -0,0 +1,223 @@ +/** @format */ + +import React, { useMemo, useRef } from 'react' +import classNames from 'classnames' +import { useQueryContext } from '../../query-context' +import { useSiteContext } from '../../site-context' +import { + BlurMenuButtonOnEscape, + isModifierPressed, + isTyping, + Keybind, + KeybindHint +} from '../../keybinding' +import { + AppNavigationLink, + useAppNavigate +} from '../../navigation/use-app-navigate' +import { + COMPARISON_DISABLED_PERIODS, + getCompareLinkItem, + last6MonthsLinkItem, + getDatePeriodGroups, + LinkItem, + QueryPeriod, + getCurrentPeriodDisplayName, + getSearchToApplyCustomDates +} from '../../query-time-periods' +import { useMatch } from 'react-router-dom' +import { rootRoute } from '../../router' +import { Popover, Transition } from '@headlessui/react' +import { popover } from '../../components/popover' +import { + datemenuButtonClassName, + DateMenuChevron, + PopoverMenuProps, + linkClassName, + MenuSeparator, + CalendarPanel, + hiddenCalendarButtonClassName +} from './shared-menu-items' +import { DateRangeCalendar } from './date-range-calendar' +import { formatISO, nowForSite } from '../../util/date' + +function QueryPeriodMenuKeybinds({ + closeDropdown, + groups +}: { + groups: LinkItem[][] + closeDropdown: () => void +}) { + const dashboardRouteMatch = useMatch(rootRoute.path) + const navigate = useAppNavigate() + + if (!dashboardRouteMatch) { + return null + } + return ( + <> + {groups.concat([[last6MonthsLinkItem]]).flatMap((group) => + group + .filter(([[_name, keyboardKey]]) => !!keyboardKey) + .map(([[_name, keyboardKey], { search, onEvent }]) => ( + { + if (typeof search === 'function') { + navigate({ search }) + } + if (typeof onEvent === 'function') { + onEvent(e) + } else { + closeDropdown() + } + }} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + targetRef="document" + /> + )) + )} + + ) +} + +export const QueryPeriodMenu = ({ + closeDropdown, + calendarButtonRef +}: PopoverMenuProps) => { + const site = useSiteContext() + const { query } = useQueryContext() + const buttonRef = useRef(null) + const toggleCalendar = () => { + if (typeof calendarButtonRef.current?.click === 'function') { + calendarButtonRef.current.click() + } + } + + return ( + <> + + + + {getCurrentPeriodDisplayName({ query, site })} + + + + + + ) +} + +const QueryPeriodMenuInner = ({ + closeDropdown, + toggleCalendar +}: { + closeDropdown: () => void + toggleCalendar: () => void +}) => { + const site = useSiteContext() + const { query } = useQueryContext() + + const groups = useMemo(() => { + const compareLink = getCompareLinkItem({ site, query }) + return getDatePeriodGroups({ + site, + onEvent: closeDropdown, + extraItemsInLastGroup: [ + [ + ['Custom Range', 'C'], + { + search: (s) => s, + isActive: ({ query }) => query.period === QueryPeriod.custom, + onEvent: toggleCalendar + } + ] + ], + extraGroups: COMPARISON_DISABLED_PERIODS.includes(query.period) + ? [] + : [[compareLink]] + }) + }, [site, query, closeDropdown, toggleCalendar]) + + return ( + <> + + + + {groups.map((group, index) => ( + + {group.map( + ([[label, keyboardKey], { search, isActive, onEvent }]) => ( + onEvent(e))} + > + {label} + {!!keyboardKey && {keyboardKey}} + + ) + )} + {index < groups.length - 1 && } + + ))} + + + + ) +} + +export const MainCalendar = ({ + closeDropdown, + calendarButtonRef +}: PopoverMenuProps) => { + const site = useSiteContext() + const { query } = useQueryContext() + const navigate = useAppNavigate() + + return ( + <> + + + + { + navigate({ + search: getSearchToApplyCustomDates(selection) + }) + closeDropdown() + }} + minDate={site.statsBegin} + maxDate={formatISO(nowForSite(site))} + defaultDates={ + query.from && query.to + ? [formatISO(query.from), formatISO(query.to)] + : undefined + } + /> + + + ) +} diff --git a/assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx b/assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx new file mode 100644 index 000000000000..12f69b9912dc --- /dev/null +++ b/assets/js/dashboard/nav-menu/query-periods/query-periods-picker.tsx @@ -0,0 +1,65 @@ +/** @format */ + +import React, { useRef } from 'react' +import classNames from 'classnames' +import { useQueryContext } from '../../query-context' +import { isComparisonEnabled } from '../../query-time-periods' +import { MovePeriodArrows } from './move-period-arrows' +import { MainCalendar, QueryPeriodMenu } from './query-period-menu' +import { + ComparisonCalendarMenu, + ComparisonPeriodMenu +} from './comparison-period-menu' +import { Popover } from '@headlessui/react' + +export function QueryPeriodsPicker({ className }: { className?: string }) { + const { query } = useQueryContext() + const isComparing = isComparisonEnabled(query.comparison) + const mainCalendarButtonRef = useRef(null) + const compareCalendarButtonRef = useRef(null) + + return ( +
+ + + {({ close }) => ( + + )} + + + {({ close }) => ( + + )} + + {isComparing && ( + <> +
+ vs. +
+ + {({ close }) => ( + + )} + + + {({ close }) => ( + + )} + + + )} +
+ ) +} diff --git a/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx b/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx new file mode 100644 index 000000000000..f1b14e1e9477 --- /dev/null +++ b/assets/js/dashboard/nav-menu/query-periods/shared-menu-items.tsx @@ -0,0 +1,76 @@ +/** @format */ + +import React, { ReactNode, RefObject } from 'react' +import classNames from 'classnames' +import { popover } from '../../components/popover' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { Popover, Transition } from '@headlessui/react' + +export const linkClassName = classNames( + popover.items.classNames.navigationLink, + popover.items.classNames.selectedOption, + popover.items.classNames.hoverLink, + popover.items.classNames.roundedStartEnd +) + +export const datemenuButtonClassName = classNames( + popover.toggleButton.classNames.rounded, + popover.toggleButton.classNames.shadow, + 'justify-between px-2 w-full' +) + +export const hiddenCalendarButtonClassName = 'flex h-9 w-0 outline-none' + +export const DateMenuChevron = () => ( + +) + +export const MenuSeparator = () => ( +
+) + +export interface PopoverMenuProps { + closeDropdown: () => void + calendarButtonRef: RefObject +} + +export enum DropdownState { + CLOSED = 'CLOSED', + MENU = 'MENU', + CALENDAR = 'CALENDAR' +} + +export interface DropdownWithCalendarState { + closeDropdown: () => void + toggleDropdown: (mode: 'menu' | 'calendar') => void + dropdownState: DropdownState + buttonRef: RefObject + toggleCalendar: () => void +} + +const calendarPositionClassName = '*:!top-auto *:!right-0 *:!absolute' + +type CalendarPanelProps = { + className?: string + children: ReactNode +} + +export const CalendarPanel = React.forwardRef< + HTMLDivElement, + CalendarPanelProps +>(({ children, className }, ref) => { + return ( + + + {children} + + + ) +}) diff --git a/assets/js/dashboard/nav-menu/top-bar.test.tsx b/assets/js/dashboard/nav-menu/top-bar.test.tsx index bc6c8f389f30..832bcdf1e5a6 100644 --- a/assets/js/dashboard/nav-menu/top-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/top-bar.test.tsx @@ -30,6 +30,15 @@ beforeAll(() => { disconnect: jest.fn() }) as unknown as IntersectionObserver ) + global.ResizeObserver = jest.fn( + () => + ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn() + }) as unknown as ResizeObserver + ) + mockAPI = new MockAPI().start() }) @@ -71,18 +80,18 @@ test('user can open and close filters dropdown', async () => { ) }) - const toggleFilters = screen.getByRole('button', { name: /Filter/ }) + const toggleFilters = screen.getByRole('button', { name: /Add filter/ }) await userEvent.click(toggleFilters) expect(screen.queryAllByRole('link').map((el) => el.textContent)).toEqual([ 'Page', + 'Hostname', 'Source', + 'UTM tags', 'Location', 'Screen size', 'Browser', 'Operating System', - 'UTM tags', - 'Goal', - 'Hostname' + 'Goal' ]) await userEvent.click(toggleFilters) expect(screen.queryAllByRole('menuitem')).toEqual([]) diff --git a/assets/js/dashboard/nav-menu/top-bar.tsx b/assets/js/dashboard/nav-menu/top-bar.tsx index d2134a4fea9c..f6b6e43dc413 100644 --- a/assets/js/dashboard/nav-menu/top-bar.tsx +++ b/assets/js/dashboard/nav-menu/top-bar.tsx @@ -5,23 +5,28 @@ import SiteSwitcher from '../site-switcher' import { useSiteContext } from '../site-context' import { useUserContext } from '../user-context' import CurrentVisitors from '../stats/current-visitors' -import QueryPeriodPicker from '../datepicker' import Filters from '../filters' import classNames from 'classnames' import { useInView } from 'react-intersection-observer' import { FilterMenu } from './filter-menu' +import { FiltersBar } from './filters-bar' +import { QueryPeriodsPicker } from './query-periods/query-periods-picker' interface TopBarProps { showCurrentVisitors: boolean - extraBar?: ReactNode } -export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) { +export function TopBar({ showCurrentVisitors }: TopBarProps) { + return ( + + + + ) +} + +function TopBarStickyWrapper({ children }: { children: ReactNode }) { const site = useSiteContext() - const user = useUserContext() - const tooltipBoundary = useRef(null) const { ref, inView } = useInView({ threshold: 0 }) - const { saved_segments } = site.flags return ( <> @@ -34,22 +39,74 @@ export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) { 'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850' )} > -
-
+ {children} +
+ + ) +} + +function TopBarInner({ showCurrentVisitors }: TopBarProps) { + const site = useSiteContext() + const user = useUserContext() + const { saved_segments } = site.flags + const leftActionsRef = useRef(null) + + return ( +
+ {saved_segments ? ( + <> +
{showCurrentVisitors && ( - + )} - {saved_segments ? : }
- -
- {!!saved_segments && !!extraBar && extraBar} -
- +
+ + filtersBarElement?.parentElement?.parentElement, + leftSection: (filtersBarElement) => + filtersBarElement?.parentElement?.parentElement + ?.firstElementChild as HTMLElement, + rightSection: (filtersBarElement) => + filtersBarElement?.parentElement?.parentElement + ?.lastElementChild as HTMLElement + }} + /> +
+
+ + +
+ + ) : ( + <> +
+ + {showCurrentVisitors && ( + + )} + +
+ + + )} +
) } diff --git a/assets/js/dashboard/query-time-periods.ts b/assets/js/dashboard/query-time-periods.ts index ea59be8fe4b4..d362df647417 100644 --- a/assets/js/dashboard/query-time-periods.ts +++ b/assets/js/dashboard/query-time-periods.ts @@ -5,7 +5,7 @@ import { clearedDateSearch, DashboardQuery } from './query' -import { PlausibleSite, useSiteContext } from './site-context' +import { PlausibleSite } from './site-context' import { formatDateRange, formatDay, @@ -24,7 +24,6 @@ import { } 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', @@ -244,142 +243,167 @@ export type LinkItem = [ site: PlausibleSite query: DashboardQuery }) => boolean - onClick?: () => void + onEvent?: (event: Pick) => void } ] -export const getDatePeriodGroups = ( +/** + * This function gets menu items with their respective navigation logic. + * Used to render both menu items and keybind listeners. + * `onEvent` is passed to all default items, but not extra items. + */ +export const getDatePeriodGroups = ({ + site, + onEvent, + extraItemsInLastGroup = [], + extraGroups = [] +}: { site: PlausibleSite -): Array> => [ - [ + onEvent?: LinkItem[1]['onEvent'] + extraItemsInLastGroup?: LinkItem[] + extraGroups?: LinkItem[][] +}): LinkItem[][] => { + const groups: 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)) - } + [ + ['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)), + onEvent + } + ], + [ + ['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)), + onEvent + } + ], + [ + ['Realtime', 'R'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod.realtime, + keybindHint: 'R' + }), + isActive: ({ query }) => query.period === QueryPeriod.realtime, + onEvent + } + ] ], [ - ['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)) - } + [ + ['Last 7 Days', 'W'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod['7d'], + keybindHint: 'W' + }), + isActive: ({ query }) => query.period === QueryPeriod['7d'], + onEvent + } + ], + [ + ['Last 30 Days', 'T'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod['30d'], + keybindHint: 'T' + }), + isActive: ({ query }) => query.period === QueryPeriod['30d'], + onEvent + } + ] ], [ - ['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'] - } + [ + ['Month to Date', 'M'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod.month, + keybindHint: 'M' + }), + isActive: ({ query }) => + query.period === QueryPeriod.month && + isSameMonth(query.date, nowForSite(site)), + onEvent + } + ], + [ + ['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)), + onEvent + } + ] ], [ - ['Last 30 Days', 'T'], - { - search: (s) => ({ - ...s, - ...clearedDateSearch, - period: QueryPeriod['30d'], - keybindHint: 'T' - }), - isActive: ({ query }) => query.period === QueryPeriod['30d'] - } + [ + ['Year to Date', 'Y'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod.year, + keybindHint: 'Y' + }), + isActive: ({ query }) => + query.period === QueryPeriod.year && isThisYear(site, query.date), + onEvent + } + ], + [ + ['Last 12 Months', 'L'], + { + search: (s) => ({ + ...s, + ...clearedDateSearch, + period: QueryPeriod['12mo'], + keybindHint: 'L' + }), + isActive: ({ query }) => query.period === QueryPeriod['12mo'], + onEvent + } + ] ] - ], - [ - [ - ['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'] - } - ] - ], - [ + ] + + const lastGroup: LinkItem[] = [ [ ['All time', 'A'], { @@ -389,11 +413,16 @@ export const getDatePeriodGroups = ( period: QueryPeriod.all, keybindHint: 'A' }), - isActive: ({ query }) => query.period === QueryPeriod.all + isActive: ({ query }) => query.period === QueryPeriod.all, + onEvent } ] ] -] + + return groups + .concat([lastGroup.concat(extraItemsInLastGroup)]) + .concat(extraGroups) +} export const last6MonthsLinkItem: LinkItem = [ ['Last 6 months', 'S'], @@ -519,9 +548,13 @@ export function getDashboardTimeSettings({ } } -export function DisplaySelectedPeriod() { - const { query } = useQueryContext() - const site = useSiteContext() +export function getCurrentPeriodDisplayName({ + query, + site +}: { + query: DashboardQuery + site: PlausibleSite +}) { if (query.period === 'day') { if (isToday(site, query.date)) { return 'Today' @@ -560,3 +593,20 @@ export function DisplaySelectedPeriod() { } return 'Realtime' } + +export function getCurrentComparisonPeriodDisplayName({ + query, + site +}: { + query: DashboardQuery + site: PlausibleSite +}) { + if (!query.comparison) { + return null + } + return query.comparison === ComparisonMode.custom && + query.compare_from && + query.compare_to + ? formatDateRange(site, query.compare_from, query.compare_to) + : COMPARISON_MODES[query.comparison] +} diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js index aee5a0b8fdf7..4919265dfd94 100644 --- a/assets/js/dashboard/site-switcher.js +++ b/assets/js/dashboard/site-switcher.js @@ -4,6 +4,7 @@ import React from 'react' import { Transition } from '@headlessui/react' import { Cog8ToothIcon, ChevronDownIcon } from '@heroicons/react/20/solid' +import classNames from 'classnames' function Favicon({ domain, className }) { return ( @@ -236,7 +237,12 @@ export default class SiteSwitcher extends React.Component { : 'cursor-default' return ( -
+
+ {this.props.children}
@@ -95,3 +98,12 @@ export default function ModalWithRouting(props) { const onClose = props.onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s })) return } + +const FocusOnMount = ({focusableRef}) => { + useEffect(() => { + if (typeof focusableRef.current?.focus === 'function') { + focusableRef.current.focus() + } + }, [focusableRef]) + return null +} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index 2f0cfd55d913..ebca07f23549 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useRef, useState } from 'react'; import * as storage from '../../util/storage'; import * as url from '../../util/url'; @@ -14,6 +14,7 @@ import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warni import { useQueryContext } from '../../query-context'; import { useSiteContext } from '../../site-context'; import { sourcesRoute, channelsRoute, utmCampaignsRoute, utmContentsRoute, utmMediumsRoute, utmSourcesRoute, utmTermsRoute } from '../../router'; +import { BlurMenuButtonOnEscape } from '../../keybinding'; const UTM_TAGS = { utm_medium: { title: 'UTM Mediums', label: 'Medium', endpoint: '/utm_mediums' }, @@ -166,6 +167,7 @@ export default function SourceList() { const [loading, setLoading] = useState(true) const [skipImportedReason, setSkipImportedReason] = useState(null) const previousQuery = usePrevious(query); + const dropdownButtonRef = useRef(null) useEffect(() => setLoading(true), [query, currentTab]) @@ -203,8 +205,9 @@ export default function SourceList() {
Sources
+
- + {buttonText} diff --git a/assets/js/dashboard/util/use-on-click-outside.ts b/assets/js/dashboard/util/use-on-click-outside.ts deleted file mode 100644 index dd2cd02f68e8..000000000000 --- a/assets/js/dashboard/util/use-on-click-outside.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { RefObject, useCallback, useEffect } from 'react' - -export function useOnClickOutside({ - ref, - active, - handler -}: { - ref: RefObject - active: boolean - handler: () => void -}) { - const onClickOutsideClose = useCallback( - (e: MouseEvent) => { - const eventTarget = e.target as Element | null - - if (ref.current && eventTarget && ref.current.contains(eventTarget)) { - return - } - handler() - }, - [ref, handler] - ) - - useEffect(() => { - const register = () => - document.addEventListener('mousedown', onClickOutsideClose) - const deregister = () => - document.removeEventListener('mousedown', onClickOutsideClose) - - if (active) { - register() - } else { - deregister() - } - - return deregister - }, [active, onClickOutsideClose]) -}