import {Fragment, useCallback, useState} from 'react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/button'; import { CompactSelect, SelectOption, SingleSelectProps, } from 'sentry/components/compactSelect'; import {Item} from 'sentry/components/dropdownAutoComplete/types'; import DropdownButton from 'sentry/components/dropdownButton'; import HookOrDefault from 'sentry/components/hookOrDefault'; import {ChangeData} from 'sentry/components/organizations/timeRangeSelector'; import DateRange from 'sentry/components/organizations/timeRangeSelector/dateRange'; import SelectorItems from 'sentry/components/organizations/timeRangeSelector/selectorItems'; import { getAbsoluteSummary, getArbitraryRelativePeriod, getSortedRelativePeriods, timeRangeAutoCompleteFilter, } from 'sentry/components/organizations/timeRangeSelector/utils'; import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {IconArrow, IconCalendar} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DateString} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import { getDateWithTimezoneInUtc, getInternalDate, getLocalToSystem, getPeriodAgo, getUserTimezone, getUtcToSystem, parsePeriodToHours, } from 'sentry/utils/dates'; import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes'; import useOrganization from 'sentry/utils/useOrganization'; import useRouter from 'sentry/utils/useRouter'; const ABSOLUTE_OPTION_VALUE = 'absolute'; const DateRangeHook = HookOrDefault({ hookName: 'component:header-date-range', defaultComponent: DateRange, }); const SelectorItemsHook = HookOrDefault({ hookName: 'component:header-selector-items', defaultComponent: SelectorItems, }); export interface TimeRangeSelectorProps extends Omit< SingleSelectProps, | 'multiple' | 'searchable' | 'disableSearchFilter' | 'options' | 'hideOptions' | 'value' | 'defaultValue' | 'onChange' | 'onInteractOutside' | 'closeOnSelect' | 'menuFooter' | 'onKeyDown' > { /** * Set an optional default value to prefill absolute date with */ defaultAbsolute?: {end?: Date; start?: Date}; /** * When the default period is selected, it is visually dimmed and makes the selector * unclearable. */ defaultPeriod?: string; /** * Forces the user to select from the set of defined relative options */ disallowArbitraryRelativeRanges?: boolean; /** * End date value for absolute date selector */ end?: DateString; /** * The largest date range (ie. end date - start date) allowed */ maxDateRange?: number; /** * The maximum number of days in the past you can pick */ maxPickableDays?: number; onChange?: (data: ChangeData) => void; /** * Relative date value */ relative?: string | null; /** * Override defaults. Accepts a function where defaultRelativeOptions = * DEFAULT_RELATIVE_PERIODS, and arbitraryRelativeOptions contains the custom * user-created periods (via the search box). */ relativeOptions?: | Record | ((props: { arbitraryOptions: Record; defaultOptions: Record; }) => Record); /** * Show absolute date selectors */ showAbsolute?: boolean; /** * Show relative date selectors */ showRelative?: boolean; /** * Start date value for absolute date selector */ start?: DateString; /** * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish) */ storageNamespace?: string; /** * Default initial value for using UTC */ utc?: boolean | null; } export function TimeRangeSelector({ start, end, utc, relative, relativeOptions, onChange, onSearch, onClose, searchPlaceholder, showAbsolute = true, showRelative = true, defaultAbsolute, defaultPeriod = DEFAULT_STATS_PERIOD, maxPickableDays = 90, maxDateRange, disallowArbitraryRelativeRanges = false, trigger, menuWidth, menuBody, ...selectProps }: TimeRangeSelectorProps) { const router = useRouter(); const organization = useOrganization(); const [search, setSearch] = useState(''); const [hasChanges, setHasChanges] = useState(false); const [hasDateRangeErrors, setHasDateRangeErrors] = useState(false); const [showAbsoluteSelector, setShowAbsoluteSelector] = useState(!showRelative); const [internalValue, setInternalValue] = useState(() => { const internalUtc = utc ?? getUserTimezone() === 'UTC'; return { start: start ? getInternalDate(start, internalUtc) : undefined, end: end ? getInternalDate(end, internalUtc) : undefined, utc: internalUtc, relative: relative ?? null, }; }); const getOptions = useCallback( (items: Item[]): SelectOption[] => { // Return the default options if there's nothing in search if (!search) { return items.map(item => { if (item.value === 'absolute') { return { value: item.value, // Wrap inside OptionLabel to offset custom margins from SelectorItemLabel // TODO: Remove SelectorItemLabel & OptionLabel label: {item.label}, details: start && end ? ( {getAbsoluteSummary(start, end, utc)} ) : null, trailingItems: ({isFocused, isSelected}) => ( ), textValue: item.searchKey, }; } return { value: item.value, label: {item.label}, textValue: item.searchKey, }; }); } const filteredItems = disallowArbitraryRelativeRanges ? items.filter(i => i.searchKey?.includes(search)) : // If arbitrary relative ranges are allowed, then generate a list of them based // on the search query timeRangeAutoCompleteFilter(items, search, { maxDays: maxPickableDays, maxDateRange, }); return filteredItems.map(item => ({ value: item.value, label: item.label, textValue: item.searchKey, })); }, [ start, end, utc, search, maxPickableDays, maxDateRange, disallowArbitraryRelativeRanges, ] ); const commitChanges = useCallback(() => { showRelative && setShowAbsoluteSelector(false); setSearch(''); if (!hasChanges) { return; } setHasChanges(false); onChange?.( internalValue.start && internalValue.end ? { ...internalValue, start: getDateWithTimezoneInUtc(internalValue.start, internalValue.utc), end: getDateWithTimezoneInUtc(internalValue.end, internalValue.utc), } : internalValue ); }, [showRelative, onChange, internalValue, hasChanges]); const handleChange = useCallback['onChange']>>( option => { // The absolute option was selected -> open absolute selector if (option.value === ABSOLUTE_OPTION_VALUE) { setInternalValue(current => { const defaultStart = defaultAbsolute?.start ? defaultAbsolute.start : getPeriodAgo( 'hours', parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD) ).toDate(); const defaultEnd = defaultAbsolute?.end ? defaultAbsolute.end : new Date(); return { ...current, // Update default values for absolute selector start: start ? getInternalDate(start, utc) : defaultStart, end: end ? getInternalDate(end, utc) : defaultEnd, }; }); setShowAbsoluteSelector(true); return; } setInternalValue(current => ({...current, relative: option.value})); onChange?.({relative: option.value, start: undefined, end: undefined}); }, [start, end, utc, defaultAbsolute, defaultPeriod, relative, onChange] ); const arbitraryRelativePeriods = getArbitraryRelativePeriod(relative); const defaultRelativePeriods = { ...DEFAULT_RELATIVE_PERIODS, ...arbitraryRelativePeriods, }; return ( handleChange({value})} > {items => ( { onSearch?.(s); setSearch(s); }} searchPlaceholder={ searchPlaceholder ?? disallowArbitraryRelativeRanges ? t('Search…') : t('Custom range: 2h, 4d, 8w…') } options={getOptions(items)} hideOptions={showAbsoluteSelector} value={start && end ? ABSOLUTE_OPTION_VALUE : relative ?? ''} onChange={handleChange} // Keep menu open when clicking on absolute range option closeOnSelect={opt => opt.value !== ABSOLUTE_OPTION_VALUE} onClose={() => { onClose?.(); setHasChanges(false); setSearch(''); }} onInteractOutside={commitChanges} onKeyDown={e => e.key === 'Escape' && commitChanges()} trigger={ trigger ?? ((triggerProps, isOpen) => { const relativeSummary = items.findIndex(item => item.value === relative) > -1 ? relative?.toUpperCase() : t('Invalid Period'); const defaultLabel = start && end ? getAbsoluteSummary(start, end, utc) : relativeSummary; return ( } > {selectProps.triggerLabel ?? defaultLabel} ); }) } menuWidth={showAbsoluteSelector ? undefined : menuWidth ?? '16rem'} menuBody={ (showAbsoluteSelector || menuBody) && ( {!showAbsoluteSelector && menuBody} {showAbsoluteSelector && ( { if (val.hasDateRangeErrors) { setHasDateRangeErrors(true); return; } setHasDateRangeErrors(false); setInternalValue(cur => ({ ...cur, relative: null, start: val.start, end: val.end, })); setHasChanges(true); }} onChangeUtc={() => { setHasChanges(true); setInternalValue(current => { const newUtc = !current.utc; const newStart = start ?? getDateWithTimezoneInUtc(current.start, current.utc); const newEnd = end ?? getDateWithTimezoneInUtc(current.end, current.utc); trackAnalytics('dateselector.utc_changed', { utc: newUtc, path: getRouteStringFromRoutes(router.routes), organization, }); return { relative: null, start: newUtc ? getLocalToSystem(newStart) : getUtcToSystem(newStart), end: newUtc ? getLocalToSystem(newEnd) : getUtcToSystem(newEnd), utc: newUtc, }; }); }} maxPickableDays={maxPickableDays} maxDateRange={maxDateRange} /> )} ) } menuFooter={ showAbsoluteSelector && (({closeOverlay}) => ( {showRelative && ( )} )) } /> )} ); } const TriggerLabel = styled('span')` ${p => p.theme.overflowEllipsis} width: auto; `; const OptionLabel = styled('span')` /* Remove custom margin added by SelectorItemLabel. Once we update custom hooks and remove SelectorItemLabel, we can delete this. */ div { margin: 0; } `; const AbsoluteSummary = styled('span')` time { white-space: nowrap; font-variant-numeric: tabular-nums; } `; const AbsoluteDateRangeWrap = styled('div')` overflow: auto; `; const StyledDateRangeHook = styled(DateRangeHook)` border: none; width: max-content; `; const AbsoluteSelectorFooter = styled('div')` display: flex; gap: ${space(1)}; justify-content: flex-end; `;