Skip to content
64 changes: 25 additions & 39 deletions components/src/TimeRangeSelector/DateTimeRangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { ReactElement, useState } from 'react';
import { ReactElement, useMemo, useState } from 'react';
import { Box, Stack, Typography, Button } from '@mui/material';
import { DateTimeField, LocalizationProvider, StaticDateTimePicker } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3';
import { AbsoluteTimeRange } from '@perses-dev/core';
import { useTimeZone } from '../context';
import { ErrorBoundary } from '../ErrorBoundary';
import { ErrorAlert } from '../ErrorAlert';
import { formatWithTimeZone } from '../utils/format';
import { DATE_TIME_FORMAT, validateDateRange } from './utils';

interface AbsoluteTimeFormProps {
initialTimeRange: AbsoluteTimeRange;
onChange: (timeRange: AbsoluteTimeRange) => void;
onCancel: () => void;
timeZone: string;
}

type AbsoluteTimeRangeInputValue = {
Expand All @@ -40,62 +41,47 @@ type AbsoluteTimeRangeInputValue = {
* @param onCancel event received when user click on cancel
* @constructor
*/
export const DateTimeRangePicker = ({ initialTimeRange, onChange, onCancel }: AbsoluteTimeFormProps): ReactElement => {
const { formatWithUserTimeZone } = useTimeZone();

export const DateTimeRangePicker = ({
initialTimeRange,
onChange,
onCancel,
timeZone,
}: AbsoluteTimeFormProps): ReactElement => {
// Time range values as dates that can be used as a time range.
const [timeRange, setTimeRange] = useState<AbsoluteTimeRange>(initialTimeRange);

// Time range values as strings used to populate the text inputs. May not
// be valid as dates when the user is typing.
const [timeRangeInputs, setTimeRangeInputs] = useState<AbsoluteTimeRangeInputValue>({
start: formatWithUserTimeZone(initialTimeRange.start, DATE_TIME_FORMAT),
end: formatWithUserTimeZone(initialTimeRange.end, DATE_TIME_FORMAT),
});
const timeRangeInputs = useMemo<AbsoluteTimeRangeInputValue>(() => {
return {
start: formatWithTimeZone(timeRange.start, DATE_TIME_FORMAT, timeZone),
end: formatWithTimeZone(timeRange.end, DATE_TIME_FORMAT, timeZone),
};
}, [timeRange.start, timeRange.end, timeZone]);

const [showStartCalendar, setShowStartCalendar] = useState<boolean>(true);

const changeTimeRange = (newTime: string | Date, segment: keyof AbsoluteTimeRange): void => {
const isInputChange = typeof newTime === 'string';
const newInputTime = isInputChange ? newTime : formatWithUserTimeZone(newTime, DATE_TIME_FORMAT);

setTimeRangeInputs((prevTimeRangeInputs) => {
const changeTimeRange = (newTime: Date, segment: keyof AbsoluteTimeRange): void => {
setTimeRange((prevTimeRange) => {
return {
...prevTimeRangeInputs,
[segment]: newInputTime,
...prevTimeRange,
[segment]: newTime,
};
});

// When the change is a string from an input, do not try to convert it to
// a date because there are likely to be interim stages of editing where it
// is not valid as a date. When the change is a Date from the calendar/clock
// interface, we can be sure it is a date.
if (!isInputChange) {
setTimeRange((prevTimeRange) => {
return {
...prevTimeRange,
[segment]: newTime,
};
});
}
};

const onChangeStartTime = (newStartTime: string | Date): void => {
const onChangeStartTime = (newStartTime: Date): void => {
changeTimeRange(newStartTime, 'start');
};

const onChangeEndTime = (newEndTime: string | Date): void => {
const onChangeEndTime = (newEndTime: Date): void => {
changeTimeRange(newEndTime, 'end');
};

const updateDateRange = (): { start: Date; end: Date } | undefined => {
const newDates = {
start: new Date(timeRangeInputs.start),
end: new Date(timeRangeInputs.end),
start: timeRange.start,
end: timeRange.end,
};
const isValidDateRange = validateDateRange(newDates.start, newDates.end);
if (isValidDateRange) {
setTimeRange(newDates);
return newDates;
}
};
Expand Down Expand Up @@ -131,7 +117,7 @@ export const DateTimeRangePicker = ({ initialTimeRange, onChange, onCancel }: Ab
displayStaticWrapperAs="desktop"
openTo="day"
disableHighlightToday={true}
value={initialTimeRange.start}
value={timeRange.start}
onChange={(newValue) => {
if (newValue === null) return;
onChangeStartTime(newValue);
Expand All @@ -157,7 +143,7 @@ export const DateTimeRangePicker = ({ initialTimeRange, onChange, onCancel }: Ab
displayStaticWrapperAs="desktop"
openTo="day"
disableHighlightToday={true}
value={initialTimeRange.end}
value={timeRange.end}
minDateTime={timeRange.start}
onChange={(newValue) => {
if (newValue === null) return;
Expand Down
121 changes: 87 additions & 34 deletions components/src/TimeRangeSelector/TimeRangeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Box, MenuItem, Popover, Select } from '@mui/material';
import { Box, MenuItem, Popover, Select, IconButton, TextField } from '@mui/material';
import Calendar from 'mdi-material-ui/Calendar';
import EarthIcon from 'mdi-material-ui/Earth';
import { TimeRangeValue, isRelativeTimeRange, AbsoluteTimeRange, toAbsoluteTimeRange } from '@perses-dev/core';
import { ReactElement, useMemo, useRef, useState } from 'react';
import { useTimeZone } from '../context';
import { TimeZoneOption, getTimeZoneOptions } from '../model/timeZoneOption';
import { TimeOption } from '../model';
import { SettingsAutocomplete, SettingsAutocompleteOption } from '../SettingsAutocomplete';
import { DateTimeRangePicker } from './DateTimeRangePicker';
import { buildCustomTimeOption, formatTimeRange } from './utils';

Expand All @@ -43,6 +46,10 @@ interface TimeRangeSelectorProps {
* Defaults to true.
*/
showCustomTimeRange?: boolean;
/** Optional explicit timezone and change handler to enable changing tz from the selector */
timeZone?: string;
timeZoneOptions?: TimeZoneOption[];
onTimeZoneChange?: (timeZone: TimeZoneOption) => void;
}

/**
Expand All @@ -57,48 +64,74 @@ export function TimeRangeSelector({
onChange,
height,
showCustomTimeRange = true,
timeZone: timeZoneProp,
timeZoneOptions,
onTimeZoneChange,
}: TimeRangeSelectorProps): ReactElement {
const { timeZone } = useTimeZone();
const { timeZone: ctxTimeZone } = useTimeZone();
const timeZone = timeZoneProp ?? ctxTimeZone;

const anchorEl = useRef(); // Used to position the absolute time range picker

// Control the open state of the absolute time range picker
const anchorEl = useRef();
const [showCustomDateSelector, setShowCustomDateSelector] = useState(false);

// Build the initial value of the absolute time range picker
const convertedTimeRange = useMemo(() => {
return isRelativeTimeRange(value) ? toAbsoluteTimeRange(value) : value;
}, [value]);

// Last option is the absolute time range option (custom)
// If the value is an absolute time range, we display this time range
// If the value is a relative time range, we make a default CustomTimeOption built from undefined value
const lastOption = useMemo(
() => buildCustomTimeOption(isRelativeTimeRange(value) ? undefined : value, timeZone),
[value, timeZone]
);

// Control the open state of the select component to prevent the menu from closing when the custom date picker is
// opened.
//
// Note that the value state of the select is here for display only. The real value (the one from props) is managed
// by click events on each menu item.
// This is a trick to get around the limitation of select with menu item that doesn't support objects as value...
const [open, setOpen] = useState(false);
const tzOptions = timeZoneOptions ?? getTimeZoneOptions();
const [tzAnchorEl, setTzAnchorEl] = useState<HTMLElement | null>(null);
const tzOpen = Boolean(tzAnchorEl);
const tzLabel = tzOptions.find((o) => o.value === timeZone)?.display ?? timeZone;
const tzAutocompleteOptions = tzOptions.map((o) => ({ id: o.value, label: o.display }));
let tzAutocompleteValue: SettingsAutocompleteOption | undefined = undefined;
{
const current = tzOptions.find((o) => o.value === timeZone);
if (current) tzAutocompleteValue = { id: current.value, label: current.display };
}

return (
<>
<Popover
anchorEl={tzAnchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
open={tzOpen}
onClose={() => setTzAnchorEl(null)}
sx={(theme) => ({ padding: theme.spacing(1) })}
>
<Box
sx={{ p: 1, minWidth: 260 }}
onClick={(e) => {
e.stopPropagation();
}}
>
<SettingsAutocomplete
options={tzAutocompleteOptions}
value={tzAutocompleteValue}
onChange={(_e, option) => {
if (option) {
const selected = tzOptions.find((o) => o.value === option.id);
if (selected) onTimeZoneChange?.(selected);
}
setTzAnchorEl(null);
}}
disableClearable
renderInput={(params) => <TextField {...params} placeholder="Search timezones" size="small" />}
/>
</Box>
</Popover>
<Popover
anchorEl={anchorEl.current}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
open={showCustomDateSelector}
onClose={() => setShowCustomDateSelector(false)}
sx={(theme) => ({
padding: theme.spacing(2),
})}
sx={(theme) => ({ padding: theme.spacing(2) })}
>
<DateTimeRangePicker
initialTimeRange={convertedTimeRange}
Expand All @@ -108,6 +141,7 @@ export function TimeRangeSelector({
setOpen(false);
}}
onCancel={() => setShowCustomDateSelector(false)}
timeZone={timeZone}
/>
</Popover>
<Box ref={anchorEl}>
Expand All @@ -116,22 +150,41 @@ export function TimeRangeSelector({
value={formatTimeRange(value, timeZone)}
onClick={() => setOpen(!open)}
IconComponent={Calendar}
inputProps={{
'aria-label': `Select time range. Currently set to ${value}`,
}}
inputProps={{ 'aria-label': `Select time range. Currently set to ${value}` }}
sx={{
// `transform: none` prevents calendar icon from flipping over when menu is open
'.MuiSelect-icon': {
marginTop: '1px',
transform: 'none',
},
// paddingRight creates more space for the calendar icon (it's a bigger icon)
'.MuiSelect-select.MuiSelect-outlined.MuiInputBase-input': {
paddingRight: '36px',
},
'.MuiSelect-icon': { marginTop: '1px', transform: 'none' },
'.MuiSelect-select.MuiSelect-outlined.MuiInputBase-input': { paddingRight: '36px' },
'.MuiSelect-select': height ? { lineHeight: height, paddingY: 0 } : {},
}}
>
<MenuItem
disableRipple
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
sx={{ cursor: 'default', '&:hover': { backgroundColor: 'transparent' }, py: 0.5, px: 1 }}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Box sx={{ typography: 'subtitle1' }}>Time Range</Box>
<Box sx={{ color: 'text.secondary', typography: 'caption', mt: 0.25 }}>Timezone: {tzLabel}</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', pr: 1, ml: 1.5 }}>
<IconButton
size="small"
aria-label="Select timezone"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTzAnchorEl(e.currentTarget);
}}
>
<EarthIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</MenuItem>
{timeOptions.map((item, idx) => (
<MenuItem
key={idx}
Expand Down
7 changes: 5 additions & 2 deletions components/src/TimeSeriesTooltip/TooltipHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Box, Divider, Typography, Stack, Switch } from '@mui/material';
import Pin from 'mdi-material-ui/Pin';
import PinOutline from 'mdi-material-ui/PinOutline';
import { memo, ReactElement } from 'react';
import { getDateAndTime } from '../utils';
import { useTimeZone } from '../context/TimeZoneProvider';
import { NearbySeriesArray } from './nearby-series';
import {
TOOLTIP_BG_COLOR_FALLBACK,
Expand Down Expand Up @@ -43,13 +43,16 @@ export const TooltipHeader = memo(function TooltipHeader({
onShowAllClick,
onUnpinClick,
}: TooltipHeaderProps) {
const { formatWithUserTimeZone } = useTimeZone();
const seriesTimeMs = nearbySeries[0]?.date ?? null;
if (seriesTimeMs === null) {
return null;
}

const formatTimeSeriesHeader = (timeMs: number): ReactElement => {
const { formattedTime, formattedDate } = getDateAndTime(timeMs);
const date = new Date(timeMs);
const formattedDate = formatWithUserTimeZone(date, 'MMM dd, yyyy - ');
const formattedTime = formatWithUserTimeZone(date, 'HH:mm:ss');
return (
<Box>
<Typography
Expand Down
Loading