index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import {ClassNames} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  6. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  7. import {GetActorPropsFn} from 'sentry/components/dropdownMenu';
  8. import HookOrDefault from 'sentry/components/hookOrDefault';
  9. import HeaderItem from 'sentry/components/organizations/headerItem';
  10. import MultipleSelectorSubmitRow from 'sentry/components/organizations/multipleSelectorSubmitRow';
  11. import PageFilterPinButton from 'sentry/components/organizations/pageFilters/pageFilterPinButton';
  12. import DateRange from 'sentry/components/organizations/timeRangeSelector/dateRange';
  13. import DateSummary from 'sentry/components/organizations/timeRangeSelector/dateSummary';
  14. import {getRelativeSummary} from 'sentry/components/organizations/timeRangeSelector/utils';
  15. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  16. import {IconCalendar} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import space from 'sentry/styles/space';
  19. import {DateString, Organization} from 'sentry/types';
  20. import {defined} from 'sentry/utils';
  21. import {analytics} from 'sentry/utils/analytics';
  22. import {
  23. getDateWithTimezoneInUtc,
  24. getInternalDate,
  25. getLocalToSystem,
  26. getPeriodAgo,
  27. getUserTimezone,
  28. getUtcToSystem,
  29. parsePeriodToHours,
  30. } from 'sentry/utils/dates';
  31. import getDynamicText from 'sentry/utils/getDynamicText';
  32. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  33. import SelectorItems from './selectorItems';
  34. const DateRangeHook = HookOrDefault({
  35. hookName: 'component:header-date-range',
  36. defaultComponent: DateRange,
  37. });
  38. const SelectorItemsHook = HookOrDefault({
  39. hookName: 'component:header-selector-items',
  40. defaultComponent: SelectorItems,
  41. });
  42. export type ChangeData = {
  43. relative: string | null;
  44. end?: Date;
  45. start?: Date;
  46. utc?: boolean | null;
  47. };
  48. type DateRangeChangeData = Parameters<
  49. React.ComponentProps<typeof DateRange>['onChange']
  50. >[0];
  51. const defaultProps = {
  52. /**
  53. * Show absolute date selectors
  54. */
  55. showAbsolute: true,
  56. /**
  57. * Show relative date selectors
  58. */
  59. showRelative: true,
  60. /**
  61. * When the default period is selected, it is visually dimmed and
  62. * makes the selector unclearable.
  63. */
  64. defaultPeriod: DEFAULT_STATS_PERIOD,
  65. /**
  66. * Callback when value changes
  67. */
  68. onChange: (() => {}) as (data: ChangeData) => void,
  69. };
  70. type Props = WithRouterProps & {
  71. /**
  72. * End date value for absolute date selector
  73. */
  74. end: DateString;
  75. /**
  76. * Callback when "Update" button is clicked
  77. */
  78. onUpdate: (data: ChangeData) => void;
  79. /**
  80. * Just used for metrics
  81. */
  82. organization: Organization;
  83. /**
  84. * Relative date value
  85. */
  86. relative: string | null;
  87. /**
  88. * Start date value for absolute date selector
  89. */
  90. start: DateString;
  91. /**
  92. * Default initial value for using UTC
  93. */
  94. utc: boolean | null;
  95. /**
  96. * Aligns dropdown menu to left or right of button
  97. */
  98. alignDropdown?: 'left' | 'right';
  99. /**
  100. * Optionally render a custom dropdown button, instead of the default
  101. * <HeaderItem />
  102. */
  103. customDropdownButton?: (config: {
  104. getActorProps: GetActorPropsFn;
  105. isOpen: boolean;
  106. }) => React.ReactElement;
  107. /**
  108. * Set an optional default value to prefill absolute date with
  109. */
  110. defaultAbsolute?: {end?: Date; start?: Date};
  111. /**
  112. * Whether the menu should be detached from the actor
  113. */
  114. detached?: boolean;
  115. /**
  116. * Small info icon with tooltip hint text
  117. */
  118. hint?: string;
  119. /**
  120. * Replace the default calendar icon for label
  121. */
  122. label?: React.ReactNode;
  123. /**
  124. * The maximum number of days in the past you can pick
  125. */
  126. maxPickableDays?: number;
  127. /**
  128. * Callback when opening/closing dropdown date selector
  129. */
  130. onToggleSelector?: (isOpen: boolean) => void;
  131. /**
  132. * Override defaults from DEFAULT_RELATIVE_PERIODS
  133. */
  134. relativeOptions?: Record<string, React.ReactNode>;
  135. } & Partial<typeof defaultProps>;
  136. type State = {
  137. hasChanges: boolean;
  138. hasDateRangeErrors: boolean;
  139. isOpen: boolean;
  140. relative: string | null;
  141. end?: Date;
  142. start?: Date;
  143. utc?: boolean | null;
  144. };
  145. class TimeRangeSelector extends React.PureComponent<Props, State> {
  146. static defaultProps = defaultProps;
  147. constructor(props: Props) {
  148. super(props);
  149. let start: Date | undefined = undefined;
  150. let end: Date | undefined = undefined;
  151. if (props.start && props.end) {
  152. start = getInternalDate(props.start, props.utc);
  153. end = getInternalDate(props.end, props.utc);
  154. }
  155. this.state = {
  156. // if utc is not null and not undefined, then use value of `props.utc` (it can be false)
  157. // otherwise if no value is supplied, the default should be the user's timezone preference
  158. utc: defined(props.utc) ? props.utc : getUserTimezone() === 'UTC',
  159. isOpen: false,
  160. hasChanges: false,
  161. hasDateRangeErrors: false,
  162. start,
  163. end,
  164. relative: props.relative,
  165. };
  166. }
  167. componentDidUpdate(_prevProps, prevState) {
  168. const {onToggleSelector} = this.props;
  169. const currState = this.state;
  170. if (onToggleSelector && prevState.isOpen !== currState.isOpen) {
  171. onToggleSelector(currState.isOpen);
  172. }
  173. }
  174. callCallback = (callback: Props['onChange'], datetime: ChangeData) => {
  175. if (typeof callback !== 'function') {
  176. return;
  177. }
  178. if (!datetime.start && !datetime.end) {
  179. callback(datetime);
  180. return;
  181. }
  182. // Change local date into either UTC or local time (local time defined by user preference)
  183. callback({
  184. ...datetime,
  185. start: getDateWithTimezoneInUtc(datetime.start, this.state.utc),
  186. end: getDateWithTimezoneInUtc(datetime.end, this.state.utc),
  187. });
  188. };
  189. handleCloseMenu = () => {
  190. const {relative, start, end, utc} = this.state;
  191. if (this.state.hasChanges) {
  192. // Only call update if we close when absolute date is selected
  193. this.handleUpdate({relative, start, end, utc});
  194. } else {
  195. this.setState({isOpen: false});
  196. }
  197. };
  198. handleUpdate = (datetime: ChangeData) => {
  199. const {onUpdate} = this.props;
  200. this.setState(
  201. {
  202. isOpen: false,
  203. hasChanges: false,
  204. },
  205. () => {
  206. this.callCallback(onUpdate, datetime);
  207. }
  208. );
  209. };
  210. handleSelect = (item: Item) => {
  211. if (item.value === 'absolute') {
  212. this.handleAbsoluteClick();
  213. return;
  214. }
  215. this.handleSelectRelative(item.value);
  216. };
  217. handleAbsoluteClick = () => {
  218. const {relative, onChange, defaultPeriod, defaultAbsolute} = this.props;
  219. // Set default range to equivalent of last relative period,
  220. // or use default stats period
  221. const newDateTime: ChangeData = {
  222. relative: null,
  223. start: defaultAbsolute?.start
  224. ? defaultAbsolute.start
  225. : getPeriodAgo(
  226. 'hours',
  227. parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD)
  228. ).toDate(),
  229. end: defaultAbsolute?.end ? defaultAbsolute.end : new Date(),
  230. };
  231. if (defined(this.props.utc)) {
  232. newDateTime.utc = this.state.utc;
  233. }
  234. this.setState({
  235. hasChanges: true,
  236. ...newDateTime,
  237. start: newDateTime.start,
  238. end: newDateTime.end,
  239. });
  240. this.callCallback(onChange, newDateTime);
  241. };
  242. handleSelectRelative = (value: string) => {
  243. const {onChange} = this.props;
  244. const newDateTime: ChangeData = {
  245. relative: value,
  246. start: undefined,
  247. end: undefined,
  248. };
  249. this.setState(newDateTime);
  250. this.callCallback(onChange, newDateTime);
  251. this.handleUpdate(newDateTime);
  252. };
  253. handleClear = () => {
  254. const {onChange, defaultPeriod} = this.props;
  255. const newDateTime: ChangeData = {
  256. relative: defaultPeriod || DEFAULT_STATS_PERIOD,
  257. start: undefined,
  258. end: undefined,
  259. utc: null,
  260. };
  261. this.setState(newDateTime);
  262. this.callCallback(onChange, newDateTime);
  263. this.handleUpdate(newDateTime);
  264. };
  265. handleSelectDateRange = ({
  266. start,
  267. end,
  268. hasDateRangeErrors = false,
  269. }: DateRangeChangeData) => {
  270. if (hasDateRangeErrors) {
  271. this.setState({hasDateRangeErrors});
  272. return;
  273. }
  274. const {onChange} = this.props;
  275. const newDateTime: ChangeData = {
  276. relative: null,
  277. start,
  278. end,
  279. };
  280. if (defined(this.props.utc)) {
  281. newDateTime.utc = this.state.utc;
  282. }
  283. this.setState({hasChanges: true, hasDateRangeErrors, ...newDateTime});
  284. this.callCallback(onChange, newDateTime);
  285. };
  286. handleUseUtc = () => {
  287. const {onChange, router} = this.props;
  288. let {start, end} = this.props;
  289. this.setState(state => {
  290. const utc = !state.utc;
  291. if (!start) {
  292. start = getDateWithTimezoneInUtc(state.start, state.utc);
  293. }
  294. if (!end) {
  295. end = getDateWithTimezoneInUtc(state.end, state.utc);
  296. }
  297. analytics('dateselector.utc_changed', {
  298. utc,
  299. path: getRouteStringFromRoutes(router.routes),
  300. org_id: parseInt(this.props.organization.id, 10),
  301. });
  302. const newDateTime = {
  303. relative: null,
  304. start: utc ? getLocalToSystem(start) : getUtcToSystem(start),
  305. end: utc ? getLocalToSystem(end) : getUtcToSystem(end),
  306. utc,
  307. };
  308. this.callCallback(onChange, newDateTime);
  309. return {
  310. hasChanges: true,
  311. ...newDateTime,
  312. };
  313. });
  314. };
  315. handleOpen = () => {
  316. this.setState({isOpen: true});
  317. // Start loading react-date-picker
  318. import('../timeRangeSelector/dateRange/index');
  319. };
  320. render() {
  321. const {
  322. defaultPeriod,
  323. showAbsolute,
  324. showRelative,
  325. organization,
  326. hint,
  327. label,
  328. relativeOptions,
  329. maxPickableDays,
  330. customDropdownButton,
  331. detached,
  332. alignDropdown,
  333. } = this.props;
  334. const {start, end, relative} = this.state;
  335. const hasNewPageFilters = organization.features.includes('selection-filters-v2');
  336. const shouldShowAbsolute = showAbsolute;
  337. const shouldShowRelative = showRelative;
  338. const isAbsoluteSelected = !!start && !!end;
  339. const summary =
  340. isAbsoluteSelected && start && end ? (
  341. <DateSummary start={start} end={end} />
  342. ) : (
  343. getRelativeSummary(
  344. relative || defaultPeriod || DEFAULT_STATS_PERIOD,
  345. relativeOptions
  346. )
  347. );
  348. return (
  349. <SelectorItemsHook
  350. shouldShowAbsolute={shouldShowAbsolute}
  351. shouldShowRelative={shouldShowRelative}
  352. relativePeriods={relativeOptions}
  353. handleSelectRelative={this.handleSelectRelative}
  354. >
  355. {items => (
  356. <ClassNames>
  357. {({css}) => (
  358. <StyledDropdownAutoComplete
  359. allowActorToggle
  360. alignMenu={alignDropdown ?? (isAbsoluteSelected ? 'right' : 'left')}
  361. isOpen={this.state.isOpen}
  362. onOpen={this.handleOpen}
  363. onClose={this.handleCloseMenu}
  364. hideInput={!shouldShowRelative}
  365. closeOnSelect={false}
  366. blendCorner={false}
  367. maxHeight={400}
  368. detached={detached}
  369. items={items}
  370. searchPlaceholder={t('Filter time range')}
  371. rootClassName={css`
  372. position: relative;
  373. display: flex;
  374. height: 100%;
  375. `}
  376. inputActions={
  377. hasNewPageFilters ? (
  378. <StyledPinButton size="xsmall" filter="datetime" />
  379. ) : undefined
  380. }
  381. onSelect={this.handleSelect}
  382. subPanel={
  383. isAbsoluteSelected && (
  384. <div>
  385. <DateRangeHook
  386. start={start ?? null}
  387. end={end ?? null}
  388. organization={organization}
  389. showTimePicker
  390. utc={this.state.utc}
  391. onChange={this.handleSelectDateRange}
  392. onChangeUtc={this.handleUseUtc}
  393. maxPickableDays={maxPickableDays}
  394. />
  395. <SubmitRow>
  396. <MultipleSelectorSubmitRow
  397. onSubmit={this.handleCloseMenu}
  398. disabled={
  399. !this.state.hasChanges || this.state.hasDateRangeErrors
  400. }
  401. />
  402. </SubmitRow>
  403. </div>
  404. )
  405. }
  406. >
  407. {({isOpen, getActorProps}) =>
  408. customDropdownButton ? (
  409. customDropdownButton({getActorProps, isOpen})
  410. ) : (
  411. <StyledHeaderItem
  412. data-test-id="global-header-timerange-selector"
  413. icon={label ?? <IconCalendar />}
  414. isOpen={isOpen}
  415. hasSelected={
  416. (!!this.props.relative &&
  417. this.props.relative !== defaultPeriod) ||
  418. isAbsoluteSelected
  419. }
  420. hasChanges={this.state.hasChanges}
  421. onClear={this.handleClear}
  422. allowClear
  423. hint={hint}
  424. {...getActorProps()}
  425. >
  426. {getDynamicText({
  427. value: summary,
  428. fixed: 'start to end',
  429. })}
  430. </StyledHeaderItem>
  431. )
  432. }
  433. </StyledDropdownAutoComplete>
  434. )}
  435. </ClassNames>
  436. )}
  437. </SelectorItemsHook>
  438. );
  439. }
  440. }
  441. const TimeRangeRoot = styled('div')`
  442. position: relative;
  443. `;
  444. const StyledDropdownAutoComplete = styled(DropdownAutoComplete)`
  445. font-size: ${p => p.theme.fontSizeMedium};
  446. position: absolute;
  447. top: 100%;
  448. ${p =>
  449. !p.detached &&
  450. `
  451. margin-top: 0;
  452. border-radius: ${p.theme.borderRadiusBottom};
  453. `};
  454. `;
  455. const StyledHeaderItem = styled(HeaderItem)`
  456. height: 100%;
  457. `;
  458. const SubmitRow = styled('div')`
  459. height: 100%;
  460. padding: ${space(0.5)} ${space(1)};
  461. border-top: 1px solid ${p => p.theme.innerBorder};
  462. border-left: 1px solid ${p => p.theme.border};
  463. `;
  464. const StyledPinButton = styled(PageFilterPinButton)`
  465. margin: 0 ${space(1)};
  466. `;
  467. export default withRouter(TimeRangeSelector);
  468. export {TimeRangeRoot};