diff --git a/static/app/components/timeRangeSelector/dateRange.tsx b/static/app/components/timeRangeSelector/dateRange.tsx new file mode 100644 index 0000000..4e53b2c --- /dev/null +++ b/static/app/components/timeRangeSelector/dateRange.tsx @@ -0,0 +1,284 @@ +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, + 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; + +type State = { + hasEndErrors: boolean; + hasStartErrors: boolean; +}; + +class BaseDateRange extends Component { + 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) => { + // 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) => { + 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 = 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 ( +
+ + {showTimePicker && ( + + + + + {t('UTC')} + + + )} +
+ ); + } +} + +function WithRoutes(props: Omit) { + const routes = useRoutes(); + return ; +} + +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; +`;