timeRangeSelector.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  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. | '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 largest date range (ie. end date - start date) allowed
  82. */
  83. maxDateRange?: number;
  84. /**
  85. * The maximum number of days in the past you can pick
  86. */
  87. maxPickableDays?: number;
  88. /**
  89. * Message to show in the menu footer
  90. */
  91. menuFooterMessage?: React.ReactNode;
  92. onChange?: (data: ChangeData) => void;
  93. /**
  94. * Relative date value
  95. */
  96. relative?: string | null;
  97. /**
  98. * Override defaults. Accepts a function where defaultRelativeOptions =
  99. * DEFAULT_RELATIVE_PERIODS, and arbitraryRelativeOptions contains the custom
  100. * user-created periods (via the search box).
  101. */
  102. relativeOptions?:
  103. | Record<string, React.ReactNode>
  104. | ((props: {
  105. arbitraryOptions: Record<string, React.ReactNode>;
  106. defaultOptions: Record<string, React.ReactNode>;
  107. }) => Record<string, React.ReactNode>);
  108. /**
  109. * Show absolute date selectors
  110. */
  111. showAbsolute?: boolean;
  112. /**
  113. * Show relative date selectors
  114. */
  115. showRelative?: boolean;
  116. /**
  117. * Start date value for absolute date selector
  118. */
  119. start?: DateString;
  120. /**
  121. * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
  122. */
  123. storageNamespace?: string;
  124. /**
  125. * Default initial value for using UTC
  126. */
  127. utc?: boolean | null;
  128. }
  129. export function TimeRangeSelector({
  130. start,
  131. end,
  132. utc,
  133. relative,
  134. relativeOptions,
  135. onChange,
  136. onSearch,
  137. onClose,
  138. searchPlaceholder,
  139. showAbsolute = true,
  140. showRelative = true,
  141. defaultAbsolute,
  142. defaultPeriod = DEFAULT_STATS_PERIOD,
  143. maxPickableDays = 90,
  144. maxDateRange,
  145. disallowArbitraryRelativeRanges = false,
  146. trigger,
  147. menuWidth,
  148. menuBody,
  149. menuFooter,
  150. menuFooterMessage,
  151. ...selectProps
  152. }: TimeRangeSelectorProps) {
  153. const router = useRouter();
  154. const organization = useOrganization();
  155. const [search, setSearch] = useState('');
  156. const [hasChanges, setHasChanges] = useState(false);
  157. const [hasDateRangeErrors, setHasDateRangeErrors] = useState(false);
  158. const [showAbsoluteSelector, setShowAbsoluteSelector] = useState(!showRelative);
  159. const [internalValue, setInternalValue] = useState<ChangeData>(() => {
  160. const internalUtc = utc ?? getUserTimezone() === 'UTC';
  161. return {
  162. start: start ? getInternalDate(start, internalUtc) : undefined,
  163. end: end ? getInternalDate(end, internalUtc) : undefined,
  164. utc: internalUtc,
  165. relative: relative ?? null,
  166. };
  167. });
  168. const getOptions = useCallback(
  169. (items: Item[]): SelectOption<string>[] => {
  170. // Return the default options if there's nothing in search
  171. if (!search) {
  172. return items.map(item => {
  173. if (item.value === 'absolute') {
  174. return {
  175. value: item.value,
  176. // Wrap inside OptionLabel to offset custom margins from SelectorItemLabel
  177. // TODO: Remove SelectorItemLabel & OptionLabel
  178. label: <OptionLabel>{item.label}</OptionLabel>,
  179. details:
  180. start && end ? (
  181. <AbsoluteSummary>{getAbsoluteSummary(start, end, utc)}</AbsoluteSummary>
  182. ) : null,
  183. trailingItems: ({isFocused, isSelected}) => (
  184. <IconArrow
  185. direction="right"
  186. size="xs"
  187. color={isFocused || isSelected ? undefined : 'subText'}
  188. />
  189. ),
  190. textValue: item.searchKey,
  191. };
  192. }
  193. return {
  194. value: item.value,
  195. label: <OptionLabel>{item.label}</OptionLabel>,
  196. textValue: item.searchKey,
  197. };
  198. });
  199. }
  200. const filteredItems = disallowArbitraryRelativeRanges
  201. ? items.filter(i => i.searchKey?.includes(search))
  202. : // If arbitrary relative ranges are allowed, then generate a list of them based
  203. // on the search query
  204. timeRangeAutoCompleteFilter(items, search, {
  205. maxDays: maxPickableDays,
  206. maxDateRange,
  207. });
  208. return filteredItems.map(item => ({
  209. value: item.value,
  210. label: item.label,
  211. textValue: item.searchKey,
  212. }));
  213. },
  214. [
  215. start,
  216. end,
  217. utc,
  218. search,
  219. maxPickableDays,
  220. maxDateRange,
  221. disallowArbitraryRelativeRanges,
  222. ]
  223. );
  224. const commitChanges = useCallback(() => {
  225. showRelative && setShowAbsoluteSelector(false);
  226. setSearch('');
  227. if (!hasChanges) {
  228. return;
  229. }
  230. setHasChanges(false);
  231. onChange?.(
  232. internalValue.start && internalValue.end
  233. ? {
  234. ...internalValue,
  235. start: getDateWithTimezoneInUtc(internalValue.start, internalValue.utc),
  236. end: getDateWithTimezoneInUtc(internalValue.end, internalValue.utc),
  237. }
  238. : internalValue
  239. );
  240. }, [showRelative, onChange, internalValue, hasChanges]);
  241. const handleChange = useCallback<NonNullable<SingleSelectProps<string>['onChange']>>(
  242. option => {
  243. // The absolute option was selected -> open absolute selector
  244. if (option.value === ABSOLUTE_OPTION_VALUE) {
  245. setInternalValue(current => {
  246. const defaultStart = defaultAbsolute?.start
  247. ? defaultAbsolute.start
  248. : getPeriodAgo(
  249. 'hours',
  250. parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD)
  251. ).toDate();
  252. const defaultEnd = defaultAbsolute?.end ? defaultAbsolute.end : new Date();
  253. return {
  254. ...current,
  255. // Update default values for absolute selector
  256. start: start ? getInternalDate(start, utc) : defaultStart,
  257. end: end ? getInternalDate(end, utc) : defaultEnd,
  258. };
  259. });
  260. setShowAbsoluteSelector(true);
  261. return;
  262. }
  263. setInternalValue(current => ({...current, relative: option.value}));
  264. onChange?.({relative: option.value, start: undefined, end: undefined});
  265. },
  266. [start, end, utc, defaultAbsolute, defaultPeriod, relative, onChange]
  267. );
  268. const arbitraryRelativePeriods = getArbitraryRelativePeriod(relative);
  269. const defaultRelativePeriods = {
  270. ...DEFAULT_RELATIVE_PERIODS,
  271. ...arbitraryRelativePeriods,
  272. };
  273. return (
  274. <SelectorItemsHook
  275. shouldShowAbsolute={showAbsolute}
  276. shouldShowRelative={showRelative}
  277. relativePeriods={getSortedRelativePeriods(
  278. typeof relativeOptions === 'function'
  279. ? relativeOptions({
  280. defaultOptions: DEFAULT_RELATIVE_PERIODS,
  281. arbitraryOptions: arbitraryRelativePeriods,
  282. })
  283. : relativeOptions ?? defaultRelativePeriods
  284. )}
  285. handleSelectRelative={value => handleChange({value})}
  286. >
  287. {items => (
  288. <CompactSelect
  289. {...selectProps}
  290. searchable={!showAbsoluteSelector}
  291. disableSearchFilter
  292. onSearch={s => {
  293. onSearch?.(s);
  294. setSearch(s);
  295. }}
  296. searchPlaceholder={
  297. searchPlaceholder ?? disallowArbitraryRelativeRanges
  298. ? t('Search…')
  299. : t('Custom range: 2h, 4d, 8w…')
  300. }
  301. options={getOptions(items)}
  302. hideOptions={showAbsoluteSelector}
  303. value={start && end ? ABSOLUTE_OPTION_VALUE : relative ?? ''}
  304. onChange={handleChange}
  305. // Keep menu open when clicking on absolute range option
  306. closeOnSelect={opt => opt.value !== ABSOLUTE_OPTION_VALUE}
  307. onClose={() => {
  308. onClose?.();
  309. setHasChanges(false);
  310. setSearch('');
  311. }}
  312. onInteractOutside={commitChanges}
  313. onKeyDown={e => e.key === 'Escape' && commitChanges()}
  314. trigger={
  315. trigger ??
  316. ((triggerProps, isOpen) => {
  317. const relativeSummary =
  318. items.findIndex(item => item.value === relative) > -1
  319. ? relative?.toUpperCase()
  320. : t('Invalid Period');
  321. const defaultLabel =
  322. start && end ? getAbsoluteSummary(start, end, utc) : relativeSummary;
  323. return (
  324. <DropdownButton
  325. isOpen={isOpen}
  326. size={selectProps.size}
  327. icon={<IconCalendar />}
  328. data-test-id="page-filter-timerange-selector"
  329. {...triggerProps}
  330. {...selectProps.triggerProps}
  331. >
  332. <TriggerLabel>{selectProps.triggerLabel ?? defaultLabel}</TriggerLabel>
  333. </DropdownButton>
  334. );
  335. })
  336. }
  337. menuWidth={showAbsoluteSelector ? undefined : menuWidth ?? '16rem'}
  338. menuBody={
  339. (showAbsoluteSelector || menuBody) && (
  340. <Fragment>
  341. {!showAbsoluteSelector && menuBody}
  342. {showAbsoluteSelector && (
  343. <AbsoluteDateRangeWrap>
  344. <StyledDateRangeHook
  345. start={internalValue.start ?? null}
  346. end={internalValue.end ?? null}
  347. utc={internalValue.utc}
  348. organization={organization}
  349. showTimePicker
  350. onChange={val => {
  351. if (val.hasDateRangeErrors) {
  352. setHasDateRangeErrors(true);
  353. return;
  354. }
  355. setHasDateRangeErrors(false);
  356. setInternalValue(cur => ({
  357. ...cur,
  358. relative: null,
  359. start: val.start,
  360. end: val.end,
  361. }));
  362. setHasChanges(true);
  363. }}
  364. onChangeUtc={() => {
  365. setHasChanges(true);
  366. setInternalValue(current => {
  367. const newUtc = !current.utc;
  368. const newStart =
  369. start ?? getDateWithTimezoneInUtc(current.start, current.utc);
  370. const newEnd =
  371. end ?? getDateWithTimezoneInUtc(current.end, current.utc);
  372. trackAnalytics('dateselector.utc_changed', {
  373. utc: newUtc,
  374. path: getRouteStringFromRoutes(router.routes),
  375. organization,
  376. });
  377. return {
  378. relative: null,
  379. start: newUtc
  380. ? getLocalToSystem(newStart)
  381. : getUtcToSystem(newStart),
  382. end: newUtc
  383. ? getLocalToSystem(newEnd)
  384. : getUtcToSystem(newEnd),
  385. utc: newUtc,
  386. };
  387. });
  388. }}
  389. maxPickableDays={maxPickableDays}
  390. maxDateRange={maxDateRange}
  391. />
  392. </AbsoluteDateRangeWrap>
  393. )}
  394. </Fragment>
  395. )
  396. }
  397. menuFooter={
  398. menuFooter || menuFooterMessage || showAbsoluteSelector
  399. ? ({closeOverlay}) => (
  400. <Fragment>
  401. {menuFooterMessage && (
  402. <FooterMessage>{menuFooterMessage}</FooterMessage>
  403. )}
  404. <FooterWrap>
  405. <FooterInnerWrap>{menuFooter}</FooterInnerWrap>
  406. {showAbsoluteSelector && (
  407. <AbsoluteSelectorFooter>
  408. {showRelative && (
  409. <Button
  410. size="xs"
  411. borderless
  412. icon={<IconArrow size="xs" direction="left" />}
  413. onClick={() => setShowAbsoluteSelector(false)}
  414. >
  415. {t('Back')}
  416. </Button>
  417. )}
  418. <Button
  419. size="xs"
  420. priority="primary"
  421. disabled={!hasChanges || hasDateRangeErrors}
  422. onClick={() => {
  423. commitChanges();
  424. closeOverlay();
  425. }}
  426. >
  427. {t('Apply')}
  428. </Button>
  429. </AbsoluteSelectorFooter>
  430. )}
  431. </FooterWrap>
  432. </Fragment>
  433. )
  434. : null
  435. }
  436. />
  437. )}
  438. </SelectorItemsHook>
  439. );
  440. }
  441. const TriggerLabel = styled('span')`
  442. ${p => p.theme.overflowEllipsis}
  443. width: auto;
  444. `;
  445. const OptionLabel = styled('span')`
  446. /* Remove custom margin added by SelectorItemLabel. Once we update custom hooks and
  447. remove SelectorItemLabel, we can delete this. */
  448. div {
  449. margin: 0;
  450. }
  451. `;
  452. const AbsoluteSummary = styled('span')`
  453. time {
  454. white-space: nowrap;
  455. font-variant-numeric: tabular-nums;
  456. }
  457. `;
  458. const AbsoluteDateRangeWrap = styled('div')`
  459. overflow: auto;
  460. `;
  461. const StyledDateRangeHook = styled(DateRangeHook)`
  462. border: none;
  463. width: max-content;
  464. `;
  465. const AbsoluteSelectorFooter = styled('div')`
  466. display: flex;
  467. gap: ${space(1)};
  468. justify-content: flex-end;
  469. `;
  470. const FooterMessage = styled('p')`
  471. padding: ${space(0.75)} ${space(1)};
  472. margin: ${space(0.5)} 0;
  473. border-radius: ${p => p.theme.borderRadius};
  474. border: solid 1px ${p => p.theme.alert.warning.border};
  475. background: ${p => p.theme.alert.warning.backgroundLight};
  476. color: ${p => p.theme.textColor};
  477. font-size: ${p => p.theme.fontSizeSmall};
  478. `;
  479. const FooterWrap = styled('div')`
  480. display: grid;
  481. grid-auto-flow: column;
  482. gap: ${space(2)};
  483. /* If there's FooterMessage above */
  484. &:not(:first-child) {
  485. margin-top: ${space(1)};
  486. }
  487. `;
  488. const FooterInnerWrap = styled('div')`
  489. grid-row: -1;
  490. display: grid;
  491. grid-auto-flow: column;
  492. gap: ${space(1)};
  493. &:empty {
  494. display: none;
  495. }
  496. &:last-of-type {
  497. justify-self: end;
  498. justify-items: end;
  499. }
  500. &:first-of-type,
  501. &:only-child {
  502. justify-self: start;
  503. justify-items: start;
  504. }
  505. `;