timeRangeSelector.tsx 15 KB

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