improved date range selector with hotkeys
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
196
apps/dashboard/src/components/time-window-picker.tsx
Normal file
196
apps/dashboard/src/components/time-window-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user