improved date range selector with hotkeys

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-27 09:52:24 +02:00
parent e2d56fb34f
commit 4059dad727
24 changed files with 520 additions and 319 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import {
parseAsBoolean,
parseAsInteger,
@@ -9,8 +10,9 @@ import {
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeRanges,
timeWindows,
} from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
const nuqsOptions = { history: 'push' } as const;
@@ -30,7 +32,7 @@ export function useOverviewOptions() {
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeRanges))
parseAsStringEnum(mapKeys(timeWindows))
.withDefault('7d')
.withOptions(nuqsOptions)
);
@@ -54,7 +56,13 @@ export function useOverviewOptions() {
previous,
setPrevious,
range,
setRange,
setRange: (value: IChartRange | null) => {
if (value !== 'custom') {
setStartDate(null);
setEndDate(null);
}
setRange(value);
},
metric,
setMetric,
startDate,

View File

@@ -5,7 +5,6 @@ import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
@@ -55,10 +54,7 @@ export function ReportInterval({ className }: ReportIntervalProps) {
value: 'month',
label: 'Month',
disabled:
range === 'today' ||
range === '24h' ||
range === '1h' ||
range === '30min',
range === 'today' || range === 'lastHour' || range === '30min',
},
]}
/>

View File

@@ -1,100 +0,0 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { cn } from '@/utils/cn';
import { format } from 'date-fns';
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
import type { SelectRangeEventHandler } from 'react-day-picker';
import { timeRanges } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
export function ReportRange({
range,
onRangeChange,
onDatesChange,
dates,
className,
...props
}: {
range: IChartRange;
onRangeChange: (range: IChartRange) => void;
onDatesChange: SelectRangeEventHandler;
dates: { startDate: string | null; endDate: string | null };
} & Omit<ExtendedComboboxProps<string>, 'value' | 'onChange'>) {
const { isBelowSm } = useBreakpoint('sm');
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={'outline'}
className={cn('justify-start text-left font-normal', className)}
icon={CalendarIcon}
{...props}
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{dates.startDate ? (
dates.endDate ? (
<>
{format(dates.startDate, 'LLL dd')} -{' '}
{format(dates.endDate, 'LLL dd')}
</>
) : (
format(dates.startDate, 'LLL dd, y')
)
) : (
<span>{range}</span>
)}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="border-b border-border p-4">
<ToggleGroup
value={range}
onValueChange={(value) => {
if (value) onRangeChange(value as IChartRange);
}}
type="single"
variant="outline"
className="flex-wrap max-sm:max-w-xs"
>
{Object.values(timeRanges).map((key) => (
<ToggleGroupItem value={key} aria-label={key} key={key}>
{key}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={
dates.startDate ? new Date(dates.startDate) : new Date()
}
selected={{
from: dates.startDate ? new Date(dates.startDate) : undefined,
to: dates.endDate ? new Date(dates.endDate) : undefined,
}}
onSelect={onDatesChange}
numberOfMonths={isBelowSm ? 1 : 2}
className="[&_table]:mx-auto [&_table]:w-auto"
/>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -1,4 +1,3 @@
import { start } from 'repl';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { isSameDay, isSameMonth } from 'date-fns';
@@ -39,7 +38,7 @@ const initialState: InitialState = {
interval: 'day',
breakdowns: [],
events: [],
range: '1m',
range: '30d',
startDate: null,
endDate: null,
previous: false,
@@ -232,10 +231,11 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
if (action.payload !== 'custom') {
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
}
},
// Formula

View File

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { pushModal, useOnPushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { CalendarIcon } from 'lucide-react';
import { timeWindows } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
function shouldIgnoreKeypress(event: KeyboardEvent) {
const tagName = (event?.target as HTMLElement)?.tagName;
const modifierPressed =
event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229;
const isTyping =
event.isComposing || tagName == 'INPUT' || tagName == 'TEXTAREA';
return modifierPressed || isTyping;
}
type Props = {
value: IChartRange;
onChange: (value: IChartRange) => void;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
endDate: string | null;
startDate: string | null;
className?: string;
};
export function TimeWindowPicker({
value,
onChange,
startDate,
onStartDateChange,
endDate,
onEndDateChange,
className,
}: Props) {
const isDateRangerPickerOpen = useRef(false);
useOnPushModal(
'DateRangerPicker',
(open) => (isDateRangerPickerOpen.current = open)
);
const timeWindow = timeWindows[value ?? '30d'];
const handleCustom = useCallback(() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
onStartDateChange(startDate.toISOString());
onEndDateChange(endDate.toISOString());
onChange('custom');
},
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}, [startDate, endDate]);
useEffect(() => {
return bind(document, {
type: 'keydown',
listener(event) {
if (shouldIgnoreKeypress(event)) {
return;
}
if (isDateRangerPickerOpen.current) {
return;
}
const match = Object.values(timeWindows).find(
(tw) => event.key === tw.shortcut.toLowerCase()
);
if (match?.key === 'custom') {
handleCustom();
} else if (match) {
onChange(match.key);
}
},
});
}, [handleCustom]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={CalendarIcon}
className={cn('justify-start', className)}
>
{timeWindow?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Time window</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onChange(timeWindows['30min'].key)}>
{timeWindows['30min'].label}
<DropdownMenuShortcut>
{timeWindows['30min'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastHour.key)}>
{timeWindows.lastHour.label}
<DropdownMenuShortcut>
{timeWindows.lastHour.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.today.key)}>
{timeWindows.today.label}
<DropdownMenuShortcut>
{timeWindows.today.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onChange(timeWindows['7d'].key)}>
{timeWindows['7d'].label}
<DropdownMenuShortcut>
{timeWindows['7d'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows['30d'].key)}>
{timeWindows['30d'].label}
<DropdownMenuShortcut>
{timeWindows['30d'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => onChange(timeWindows.monthToDate.key)}
>
{timeWindows.monthToDate.label}
<DropdownMenuShortcut>
{timeWindows.monthToDate.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastMonth.key)}>
{timeWindows.lastMonth.label}
<DropdownMenuShortcut>
{timeWindows.lastMonth.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => onChange(timeWindows.yearToDate.key)}
>
{timeWindows.yearToDate.label}
<DropdownMenuShortcut>
{timeWindows.yearToDate.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastYear.key)}>
{timeWindows.lastYear.label}
<DropdownMenuShortcut>
{timeWindows.lastYear.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => handleCustom()}>
{timeWindows.custom.label}
<DropdownMenuShortcut>
{timeWindows.custom.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -10,7 +10,7 @@ import { Loader2 } from 'lucide-react';
import Link from 'next/link';
const buttonVariants = cva(
'inline-flex flex-shrink-0 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {