With UTC enabled, the absolute date range picker still capped the calendar at the local `new Date()`. When the local date was a day behind UTC, today's UTC date was disabled and could not be picked. Convert the max date with `getUtcToSystem` when `utc` is set, the same conversion the selector already applies to the start and end dates.
286 lines
7.9 KiB
TypeScript
286 lines
7.9 KiB
TypeScript
import {Component} from 'react';
|
|
import type {Range} from 'react-date-range';
|
|
import type {Theme} from '@emotion/react';
|
|
import {withTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import moment from 'moment-timezone';
|
|
|
|
import {Checkbox} from '@sentry/scraps/checkbox';
|
|
|
|
import {DateRangePicker} from 'sentry/components/calendar';
|
|
import {MAX_PICKABLE_DAYS} from 'sentry/constants';
|
|
import {t} from 'sentry/locale';
|
|
import type {PlainRoute} from 'sentry/types/legacyReactRouter';
|
|
import type {Organization} from 'sentry/types/organization';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import {
|
|
getEndOfDay,
|
|
getStartOfPeriodAgo,
|
|
getUtcToSystem,
|
|
isValidTime,
|
|
setDateToTime,
|
|
} from 'sentry/utils/dates';
|
|
import {getRouteStringFromRoutes} from 'sentry/utils/getRouteStringFromRoutes';
|
|
import {useRoutes} from 'sentry/utils/useRoutes';
|
|
|
|
import {TimePicker} from './timePicker';
|
|
|
|
const getTimeStringFromDate = (date: Date) => moment(date).local().format('HH:mm');
|
|
|
|
type ChangeData = {end?: Date; hasDateRangeErrors?: boolean; start?: Date};
|
|
|
|
const defaultProps = {
|
|
showAbsolute: true,
|
|
showRelative: false,
|
|
/**
|
|
* The maximum number of days in the past you can pick
|
|
*/
|
|
maxPickableDays: MAX_PICKABLE_DAYS,
|
|
};
|
|
|
|
type Props = {
|
|
/**
|
|
* End date value for absolute date selector
|
|
*/
|
|
end: Date | null;
|
|
/**
|
|
* Callback when value changes
|
|
*/
|
|
onChange: (data: ChangeData) => void;
|
|
|
|
/**
|
|
* handle UTC checkbox change
|
|
*/
|
|
onChangeUtc: () => void;
|
|
|
|
/**
|
|
* The matched routes, used to derive a parameterized path for analytics.
|
|
*/
|
|
routes: PlainRoute[];
|
|
|
|
/**
|
|
* Start date value for absolute date selector
|
|
*/
|
|
start: Date | null;
|
|
|
|
theme: Theme;
|
|
|
|
className?: string;
|
|
|
|
/**
|
|
* The largest date range (ie. end date - start date) allowed
|
|
*/
|
|
maxDateRange?: number;
|
|
|
|
/**
|
|
* Just used for metrics
|
|
*/
|
|
organization?: Organization;
|
|
|
|
/**
|
|
* Should we have a time selector?
|
|
*/
|
|
showTimePicker?: boolean;
|
|
|
|
/**
|
|
* Use UTC
|
|
*/
|
|
utc?: boolean | null;
|
|
} & Partial<typeof defaultProps>;
|
|
|
|
type State = {
|
|
hasEndErrors: boolean;
|
|
hasStartErrors: boolean;
|
|
};
|
|
|
|
class BaseDateRange extends Component<Props, State> {
|
|
static defaultProps = defaultProps;
|
|
|
|
state: State = {
|
|
hasStartErrors: false,
|
|
hasEndErrors: false,
|
|
};
|
|
|
|
private readonly utcInputId =
|
|
'utc-picker-' + Math.random().toString(36).substring(2, 12);
|
|
|
|
handleSelectDateRange = (range: Range) => {
|
|
const {onChange} = this.props;
|
|
const {startDate, endDate} = range;
|
|
const end = endDate ? getEndOfDay(endDate) : endDate;
|
|
|
|
onChange({
|
|
start: startDate,
|
|
end,
|
|
});
|
|
};
|
|
|
|
handleChangeStart = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
// Safari does not support "time" inputs, so we don't have access to
|
|
// `e.target.valueAsDate`, must parse as string
|
|
//
|
|
// Time will be in 24hr e.g. "21:00"
|
|
const start = this.props.start ?? '';
|
|
const end = this.props.end ?? undefined;
|
|
const {onChange, organization, routes} = this.props;
|
|
const startTime = e.target.value;
|
|
const newStartTime = setDateToTime(start, startTime, {local: true});
|
|
|
|
if (!startTime || !isValidTime(startTime) || (end && newStartTime > end)) {
|
|
this.setState({hasStartErrors: true});
|
|
onChange({hasDateRangeErrors: true});
|
|
return;
|
|
}
|
|
|
|
trackAnalytics('dateselector.time_changed', {
|
|
organization: organization ?? null,
|
|
field_changed: 'start',
|
|
time: startTime,
|
|
path: getRouteStringFromRoutes({routes}),
|
|
});
|
|
|
|
onChange({
|
|
start: newStartTime,
|
|
end,
|
|
hasDateRangeErrors: this.state.hasEndErrors,
|
|
});
|
|
|
|
this.setState({hasStartErrors: false});
|
|
};
|
|
|
|
handleChangeEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const start = this.props.start ?? undefined;
|
|
const end = this.props.end ?? '';
|
|
const {organization, onChange, routes} = this.props;
|
|
const endTime = e.target.value;
|
|
const newEndTime = setDateToTime(end, endTime, {local: true});
|
|
|
|
if (!endTime || !isValidTime(endTime) || (start && start > newEndTime)) {
|
|
this.setState({hasEndErrors: true});
|
|
onChange({hasDateRangeErrors: true});
|
|
return;
|
|
}
|
|
|
|
trackAnalytics('dateselector.time_changed', {
|
|
organization: organization ?? null,
|
|
field_changed: 'end',
|
|
time: endTime,
|
|
path: getRouteStringFromRoutes({routes}),
|
|
});
|
|
|
|
onChange({
|
|
start,
|
|
end: newEndTime,
|
|
hasDateRangeErrors: this.state.hasStartErrors,
|
|
});
|
|
|
|
this.setState({hasEndErrors: false});
|
|
};
|
|
|
|
render() {
|
|
const {className, maxPickableDays, utc, showTimePicker, onChangeUtc, maxDateRange} =
|
|
this.props;
|
|
const {hasStartErrors, hasEndErrors} = this.state;
|
|
const start = this.props.start ?? '';
|
|
const end = this.props.end ?? '';
|
|
|
|
const startTime = getTimeStringFromDate(new Date(start));
|
|
const endTime = getTimeStringFromDate(new Date(end));
|
|
|
|
// Restraints on the time range that you can select
|
|
// Can't select dates in the future b/c we're not fortune tellers (yet)
|
|
//
|
|
// We want `maxPickableDays` - 1 (if today is Jan 5, max is 3 days, the minDate should be Jan 3)
|
|
// Subtract additional day because we force the end date to be inclusive,
|
|
// so when you pick Jan 1 the time becomes Jan 1 @ 23:59:59,
|
|
// (or really, Jan 2 @ 00:00:00 - 1 second), while the start time is at 00:00
|
|
let minDate = getStartOfPeriodAgo('days', (maxPickableDays ?? MAX_PICKABLE_DAYS) - 1);
|
|
|
|
let maxDate = utc ? getUtcToSystem(new Date()) : new Date();
|
|
|
|
// if the start and end date are the same, it means the user can still select another date,
|
|
// at this point we want to apply the maxDateRange
|
|
const startDate = moment(start).local();
|
|
const endDate = moment(end).local();
|
|
const isSameDay = startDate.isSame(endDate, 'day');
|
|
if (maxDateRange && isSameDay) {
|
|
minDate = moment(new Date(start)).subtract(maxDateRange, 'days').toDate();
|
|
const newMaxDate = moment(new Date(end)).add(maxDateRange, 'days').toDate();
|
|
maxDate = newMaxDate > maxDate ? maxDate : newMaxDate;
|
|
}
|
|
|
|
return (
|
|
<div className={className} data-test-id="date-range">
|
|
<DateRangePicker
|
|
startDate={startDate.toDate()}
|
|
endDate={endDate.toDate()}
|
|
minDate={minDate}
|
|
maxDate={maxDate}
|
|
onChange={this.handleSelectDateRange}
|
|
/>
|
|
{showTimePicker && (
|
|
<TimeAndUtcPicker>
|
|
<StyledTimePicker
|
|
start={startTime}
|
|
end={endTime}
|
|
onChangeStart={this.handleChangeStart}
|
|
onChangeEnd={this.handleChangeEnd}
|
|
hasStartErrors={hasStartErrors}
|
|
hasEndErrors={hasEndErrors}
|
|
/>
|
|
<UtcPicker>
|
|
<Checkbox
|
|
onChange={onChangeUtc}
|
|
checked={utc || false}
|
|
id={this.utcInputId}
|
|
/>
|
|
<UtcPickerLabel htmlFor={this.utcInputId}>{t('UTC')}</UtcPickerLabel>
|
|
</UtcPicker>
|
|
</TimeAndUtcPicker>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
function WithRoutes(props: Omit<Props, 'routes'>) {
|
|
const routes = useRoutes();
|
|
return <BaseDateRange {...props} routes={routes} />;
|
|
}
|
|
|
|
export const DateRange = styled(withTheme(WithRoutes))`
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-left: 1px solid ${p => p.theme.tokens.border.primary};
|
|
`;
|
|
|
|
const TimeAndUtcPicker = styled('div')`
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 ${p => p.theme.space.xl};
|
|
padding: ${p => p.theme.space.xs} 0;
|
|
border-top: 1px solid ${p => p.theme.tokens.border.secondary};
|
|
`;
|
|
|
|
const StyledTimePicker = styled(TimePicker)`
|
|
&& {
|
|
margin-left: 0;
|
|
}
|
|
`;
|
|
|
|
const UtcPicker = styled('div')`
|
|
color: ${p => p.theme.tokens.content.secondary};
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
flex: 1;
|
|
gap: ${p => p.theme.space.xs};
|
|
`;
|
|
|
|
const UtcPickerLabel = styled('label')`
|
|
margin: 0;
|
|
font-weight: ${p => p.theme.font.weight.sans.regular};
|
|
color: inherit;
|
|
`;
|