timeRangeSelector.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import {Fragment, useCallback, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {
  5. CompactSelect,
  6. SelectOption,
  7. SingleSelectProps,
  8. } from 'sentry/components/compactSelect';
  9. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  10. import DropdownButton from 'sentry/components/dropdownButton';
  11. import HookOrDefault from 'sentry/components/hookOrDefault';
  12. import {ChangeData} from 'sentry/components/organizations/timeRangeSelector';
  13. import DateRange from 'sentry/components/organizations/timeRangeSelector/dateRange';
  14. import SelectorItems from 'sentry/components/organizations/timeRangeSelector/selectorItems';
  15. import {
  16. getAbsoluteSummary,
  17. getDefaultRelativePeriods,
  18. timeRangeAutoCompleteFilter,
  19. } from 'sentry/components/organizations/timeRangeSelector/utils';
  20. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  21. import {IconArrow, IconCalendar} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import {DateString} from 'sentry/types';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import {
  27. getDateWithTimezoneInUtc,
  28. getInternalDate,
  29. getLocalToSystem,
  30. getPeriodAgo,
  31. getUserTimezone,
  32. getUtcToSystem,
  33. parsePeriodToHours,
  34. } from 'sentry/utils/dates';
  35. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import useRouter from 'sentry/utils/useRouter';
  38. const ABSOLUTE_OPTION_VALUE = 'absolute';
  39. const DateRangeHook = HookOrDefault({
  40. hookName: 'component:header-date-range',
  41. defaultComponent: DateRange,
  42. });
  43. const SelectorItemsHook = HookOrDefault({
  44. hookName: 'component:header-selector-items',
  45. defaultComponent: SelectorItems,
  46. });
  47. export interface TimeRangeSelectorProps
  48. extends Omit<
  49. SingleSelectProps<string>,
  50. | 'multiple'
  51. | 'searchable'
  52. | 'disableSearchFilter'
  53. | 'options'
  54. | 'hideOptions'
  55. | 'value'
  56. | 'defaultValue'
  57. | 'onChange'
  58. | 'onInteractOutside'
  59. | 'closeOnSelect'
  60. | 'menuFooter'
  61. | 'onKeyDown'
  62. > {
  63. /**
  64. * Set an optional default value to prefill absolute date with
  65. */
  66. defaultAbsolute?: {end?: Date; start?: Date};
  67. /**
  68. * When the default period is selected, it is visually dimmed and makes the selector
  69. * unclearable.
  70. */
  71. defaultPeriod?: string;
  72. /**
  73. * Forces the user to select from the set of defined relative options
  74. */
  75. disallowArbitraryRelativeRanges?: boolean;
  76. /**
  77. * End date value for absolute date selector
  78. */
  79. end?: DateString;
  80. /**
  81. * The maximum number of days in the past you can pick
  82. */
  83. maxPickableDays?: number;
  84. onChange?: (data: ChangeData) => void;
  85. /**
  86. * Relative date value
  87. */
  88. relative?: string | null;
  89. /**
  90. * Override defaults from DEFAULT_RELATIVE_PERIODS
  91. */
  92. relativeOptions?: Record<string, React.ReactNode>;
  93. /**
  94. * Show absolute date selectors
  95. */
  96. showAbsolute?: boolean;
  97. /**
  98. * Show relative date selectors
  99. */
  100. showRelative?: boolean;
  101. /**
  102. * Start date value for absolute date selector
  103. */
  104. start?: DateString;
  105. /**
  106. * Default initial value for using UTC
  107. */
  108. utc?: boolean | null;
  109. }
  110. export function TimeRangeSelector({
  111. start,
  112. end,
  113. utc,
  114. relative,
  115. relativeOptions,
  116. onChange,
  117. onSearch,
  118. onClose,
  119. searchPlaceholder,
  120. showAbsolute = true,
  121. showRelative = true,
  122. defaultAbsolute,
  123. defaultPeriod = DEFAULT_STATS_PERIOD,
  124. maxPickableDays = 90,
  125. disallowArbitraryRelativeRanges = false,
  126. trigger,
  127. menuWidth,
  128. menuBody,
  129. ...selectProps
  130. }: TimeRangeSelectorProps) {
  131. const router = useRouter();
  132. const organization = useOrganization();
  133. const [search, setSearch] = useState('');
  134. const [hasChanges, setHasChanges] = useState(false);
  135. const [hasDateRangeErrors, setHasDateRangeErrors] = useState(false);
  136. const [showAbsoluteSelector, setShowAbsoluteSelector] = useState(!showRelative);
  137. const [internalValue, setInternalValue] = useState<ChangeData>(() => {
  138. const internalUtc = utc ?? getUserTimezone() === 'UTC';
  139. return {
  140. start: start ? getInternalDate(start, internalUtc) : undefined,
  141. end: end ? getInternalDate(end, internalUtc) : undefined,
  142. utc: internalUtc,
  143. relative: relative ?? null,
  144. };
  145. });
  146. const getOptions = useCallback(
  147. (items: Item[]): SelectOption<string>[] => {
  148. // Return the default options if there's nothing in search
  149. if (!search) {
  150. return items.map(item => {
  151. if (item.value === 'absolute') {
  152. return {
  153. value: item.value,
  154. // Wrap inside OptionLabel to offset custom margins from SelectorItemLabel
  155. // TODO: Remove SelectorItemLabel & OptionLabel
  156. label: <OptionLabel>{item.label}</OptionLabel>,
  157. details:
  158. start && end ? (
  159. <AbsoluteSummary>{getAbsoluteSummary(start, end, utc)}</AbsoluteSummary>
  160. ) : null,
  161. trailingItems: ({isFocused, isSelected}) => (
  162. <IconArrow
  163. direction="right"
  164. size="xs"
  165. color={isFocused || isSelected ? undefined : 'subText'}
  166. />
  167. ),
  168. textValue: item.searchKey,
  169. };
  170. }
  171. return {
  172. value: item.value,
  173. label: <OptionLabel>{item.label}</OptionLabel>,
  174. textValue: item.searchKey,
  175. };
  176. });
  177. }
  178. const filteredItems = disallowArbitraryRelativeRanges
  179. ? items.filter(i => i.searchKey?.includes(search))
  180. : // If arbitrary relative ranges are allowed, then generate a list of them based
  181. // on the search query
  182. timeRangeAutoCompleteFilter(items, search, {
  183. maxDays: maxPickableDays,
  184. });
  185. return filteredItems.map(item => ({
  186. value: item.value,
  187. label: item.label,
  188. textValue: item.searchKey,
  189. }));
  190. },
  191. [start, end, utc, search, maxPickableDays, disallowArbitraryRelativeRanges]
  192. );
  193. const commitChanges = useCallback(() => {
  194. showRelative && setShowAbsoluteSelector(false);
  195. setSearch('');
  196. if (!hasChanges) {
  197. return;
  198. }
  199. setHasChanges(false);
  200. onChange?.(
  201. internalValue.start && internalValue.end
  202. ? {
  203. ...internalValue,
  204. start: getDateWithTimezoneInUtc(internalValue.start, internalValue.utc),
  205. end: getDateWithTimezoneInUtc(internalValue.end, internalValue.utc),
  206. }
  207. : internalValue
  208. );
  209. }, [showRelative, onChange, internalValue, hasChanges]);
  210. const handleChange = useCallback<NonNullable<SingleSelectProps<string>['onChange']>>(
  211. option => {
  212. // The absolute option was selected -> open absolute selector
  213. if (option.value === ABSOLUTE_OPTION_VALUE) {
  214. setInternalValue(current => ({
  215. ...current,
  216. // Update default values for absolute selector
  217. start: defaultAbsolute?.start
  218. ? defaultAbsolute.start
  219. : getPeriodAgo(
  220. 'hours',
  221. parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD)
  222. ).toDate(),
  223. end: defaultAbsolute?.end ? defaultAbsolute.end : new Date(),
  224. }));
  225. setShowAbsoluteSelector(true);
  226. return;
  227. }
  228. setInternalValue(current => ({...current, relative: option.value}));
  229. onChange?.({relative: option.value, start: undefined, end: undefined});
  230. },
  231. [defaultAbsolute, defaultPeriod, relative, onChange]
  232. );
  233. return (
  234. <SelectorItemsHook
  235. shouldShowAbsolute={showAbsolute}
  236. shouldShowRelative={showRelative}
  237. relativePeriods={relativeOptions ?? getDefaultRelativePeriods(relative)}
  238. handleSelectRelative={value => handleChange({value})}
  239. >
  240. {items => (
  241. <CompactSelect
  242. {...selectProps}
  243. searchable={!showAbsoluteSelector}
  244. disableSearchFilter
  245. onSearch={s => {
  246. onSearch?.(s);
  247. setSearch(s);
  248. }}
  249. searchPlaceholder={
  250. searchPlaceholder ?? disallowArbitraryRelativeRanges
  251. ? t('Search…')
  252. : t('Custom range: 2h, 4d, 8w…')
  253. }
  254. options={getOptions(items)}
  255. hideOptions={showAbsoluteSelector}
  256. value={start && end ? ABSOLUTE_OPTION_VALUE : relative ?? ''}
  257. onChange={handleChange}
  258. // Keep menu open when clicking on absolute range option
  259. closeOnSelect={opt => opt.value !== ABSOLUTE_OPTION_VALUE}
  260. onClose={() => {
  261. onClose?.();
  262. setHasChanges(false);
  263. setSearch('');
  264. }}
  265. onInteractOutside={commitChanges}
  266. onKeyDown={e => e.key === 'Escape' && commitChanges()}
  267. trigger={
  268. trigger ??
  269. (triggerProps => {
  270. const relativeSummary =
  271. items.findIndex(item => item.value === relative) > -1
  272. ? relative?.toUpperCase()
  273. : t('Invalid Period');
  274. const defaultLabel =
  275. start && end ? getAbsoluteSummary(start, end, utc) : relativeSummary;
  276. return (
  277. <DropdownButton icon={<IconCalendar />} {...triggerProps}>
  278. <TriggerLabel>{selectProps.triggerLabel ?? defaultLabel}</TriggerLabel>
  279. </DropdownButton>
  280. );
  281. })
  282. }
  283. menuWidth={showAbsoluteSelector ? undefined : menuWidth ?? '16rem'}
  284. menuBody={
  285. (showAbsoluteSelector || menuBody) && (
  286. <Fragment>
  287. {!showAbsoluteSelector && menuBody}
  288. {showAbsoluteSelector && (
  289. <AbsoluteDateRangeWrap>
  290. <StyledDateRangeHook
  291. start={internalValue.start ?? null}
  292. end={internalValue.end ?? null}
  293. utc={internalValue.utc}
  294. organization={organization}
  295. showTimePicker
  296. onChange={val => {
  297. val.hasDateRangeErrors && setHasDateRangeErrors(true);
  298. setInternalValue(cur => ({
  299. ...cur,
  300. relative: null,
  301. start: val.start,
  302. end: val.end,
  303. }));
  304. setHasChanges(true);
  305. }}
  306. onChangeUtc={() => {
  307. setHasChanges(true);
  308. setInternalValue(current => {
  309. const newUtc = !current.utc;
  310. const newStart =
  311. start ?? getDateWithTimezoneInUtc(current.start, current.utc);
  312. const newEnd =
  313. end ?? getDateWithTimezoneInUtc(current.end, current.utc);
  314. trackAnalytics('dateselector.utc_changed', {
  315. utc: newUtc,
  316. path: getRouteStringFromRoutes(router.routes),
  317. organization,
  318. });
  319. return {
  320. relative: null,
  321. start: newUtc
  322. ? getLocalToSystem(newStart)
  323. : getUtcToSystem(newStart),
  324. end: newUtc
  325. ? getLocalToSystem(newEnd)
  326. : getUtcToSystem(newEnd),
  327. utc: newUtc,
  328. };
  329. });
  330. }}
  331. maxPickableDays={maxPickableDays}
  332. />
  333. </AbsoluteDateRangeWrap>
  334. )}
  335. </Fragment>
  336. )
  337. }
  338. menuFooter={
  339. showAbsoluteSelector &&
  340. (({closeOverlay}) => (
  341. <AbsoluteSelectorFooter>
  342. {showRelative && (
  343. <Button
  344. size="xs"
  345. borderless
  346. icon={<IconArrow size="xs" direction="left" />}
  347. onClick={() => setShowAbsoluteSelector(false)}
  348. >
  349. {t('Back')}
  350. </Button>
  351. )}
  352. <Button
  353. size="xs"
  354. priority="primary"
  355. disabled={!hasChanges || hasDateRangeErrors}
  356. onClick={() => {
  357. commitChanges();
  358. closeOverlay();
  359. }}
  360. >
  361. {t('Apply')}
  362. </Button>
  363. </AbsoluteSelectorFooter>
  364. ))
  365. }
  366. />
  367. )}
  368. </SelectorItemsHook>
  369. );
  370. }
  371. const TriggerLabel = styled('span')`
  372. ${p => p.theme.overflowEllipsis}
  373. width: auto;
  374. `;
  375. const OptionLabel = styled('span')`
  376. /* Remove custom margin added by SelectorItemLabel. Once we update custom hooks and
  377. remove SelectorItemLabel, we can delete this. */
  378. div {
  379. margin: 0;
  380. }
  381. `;
  382. const AbsoluteSummary = styled('span')`
  383. time {
  384. white-space: nowrap;
  385. font-variant-numeric: tabular-nums;
  386. }
  387. `;
  388. const AbsoluteDateRangeWrap = styled('div')`
  389. overflow: auto;
  390. `;
  391. const StyledDateRangeHook = styled(DateRangeHook)`
  392. border: none;
  393. width: max-content;
  394. `;
  395. const AbsoluteSelectorFooter = styled('div')`
  396. display: flex;
  397. gap: ${space(1)};
  398. justify-content: flex-end;
  399. `;