index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import {PureComponent} 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. /**
  136. * Show the pin button in the dropdown's header actions
  137. */
  138. showPin?: boolean;
  139. } & Partial<typeof defaultProps>;
  140. type State = {
  141. hasChanges: boolean;
  142. hasDateRangeErrors: boolean;
  143. isOpen: boolean;
  144. relative: string | null;
  145. end?: Date;
  146. start?: Date;
  147. utc?: boolean | null;
  148. };
  149. class TimeRangeSelector extends PureComponent<Props, State> {
  150. static defaultProps = defaultProps;
  151. constructor(props: Props) {
  152. super(props);
  153. let start: Date | undefined = undefined;
  154. let end: Date | undefined = undefined;
  155. if (props.start && props.end) {
  156. start = getInternalDate(props.start, props.utc);
  157. end = getInternalDate(props.end, props.utc);
  158. }
  159. this.state = {
  160. // if utc is not null and not undefined, then use value of `props.utc` (it can be false)
  161. // otherwise if no value is supplied, the default should be the user's timezone preference
  162. utc: defined(props.utc) ? props.utc : getUserTimezone() === 'UTC',
  163. isOpen: false,
  164. hasChanges: false,
  165. hasDateRangeErrors: false,
  166. start,
  167. end,
  168. relative: props.relative,
  169. };
  170. }
  171. componentDidUpdate(_prevProps, prevState) {
  172. const {onToggleSelector} = this.props;
  173. const currState = this.state;
  174. if (onToggleSelector && prevState.isOpen !== currState.isOpen) {
  175. onToggleSelector(currState.isOpen);
  176. }
  177. }
  178. callCallback = (callback: Props['onChange'], datetime: ChangeData) => {
  179. if (typeof callback !== 'function') {
  180. return;
  181. }
  182. if (!datetime.start && !datetime.end) {
  183. callback(datetime);
  184. return;
  185. }
  186. // Change local date into either UTC or local time (local time defined by user preference)
  187. callback({
  188. ...datetime,
  189. start: getDateWithTimezoneInUtc(datetime.start, this.state.utc),
  190. end: getDateWithTimezoneInUtc(datetime.end, this.state.utc),
  191. });
  192. };
  193. handleCloseMenu = () => {
  194. const {relative, start, end, utc} = this.state;
  195. if (this.state.hasChanges) {
  196. // Only call update if we close when absolute date is selected
  197. this.handleUpdate({relative, start, end, utc});
  198. } else {
  199. this.setState({isOpen: false});
  200. }
  201. };
  202. handleUpdate = (datetime: ChangeData) => {
  203. const {onUpdate} = this.props;
  204. this.setState(
  205. {
  206. isOpen: false,
  207. hasChanges: false,
  208. },
  209. () => {
  210. this.callCallback(onUpdate, datetime);
  211. }
  212. );
  213. };
  214. handleSelect = (item: Item) => {
  215. if (item.value === 'absolute') {
  216. this.handleAbsoluteClick();
  217. return;
  218. }
  219. this.handleSelectRelative(item.value);
  220. };
  221. handleAbsoluteClick = () => {
  222. const {relative, onChange, defaultPeriod, defaultAbsolute} = this.props;
  223. // Set default range to equivalent of last relative period,
  224. // or use default stats period
  225. const newDateTime: ChangeData = {
  226. relative: null,
  227. start: defaultAbsolute?.start
  228. ? defaultAbsolute.start
  229. : getPeriodAgo(
  230. 'hours',
  231. parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD)
  232. ).toDate(),
  233. end: defaultAbsolute?.end ? defaultAbsolute.end : new Date(),
  234. };
  235. if (defined(this.props.utc)) {
  236. newDateTime.utc = this.state.utc;
  237. }
  238. this.setState({
  239. hasChanges: true,
  240. ...newDateTime,
  241. start: newDateTime.start,
  242. end: newDateTime.end,
  243. });
  244. this.callCallback(onChange, newDateTime);
  245. };
  246. handleSelectRelative = (value: string) => {
  247. const {onChange} = this.props;
  248. const newDateTime: ChangeData = {
  249. relative: value,
  250. start: undefined,
  251. end: undefined,
  252. };
  253. this.setState(newDateTime);
  254. this.callCallback(onChange, newDateTime);
  255. this.handleUpdate(newDateTime);
  256. };
  257. handleClear = () => {
  258. const {onChange, defaultPeriod} = this.props;
  259. const newDateTime: ChangeData = {
  260. relative: defaultPeriod || DEFAULT_STATS_PERIOD,
  261. start: undefined,
  262. end: undefined,
  263. utc: null,
  264. };
  265. this.setState(newDateTime);
  266. this.callCallback(onChange, newDateTime);
  267. this.handleUpdate(newDateTime);
  268. };
  269. handleSelectDateRange = ({
  270. start,
  271. end,
  272. hasDateRangeErrors = false,
  273. }: DateRangeChangeData) => {
  274. if (hasDateRangeErrors) {
  275. this.setState({hasDateRangeErrors});
  276. return;
  277. }
  278. const {onChange} = this.props;
  279. const newDateTime: ChangeData = {
  280. relative: null,
  281. start,
  282. end,
  283. };
  284. if (defined(this.props.utc)) {
  285. newDateTime.utc = this.state.utc;
  286. }
  287. this.setState({hasChanges: true, hasDateRangeErrors, ...newDateTime});
  288. this.callCallback(onChange, newDateTime);
  289. };
  290. handleUseUtc = () => {
  291. const {onChange, router} = this.props;
  292. let {start, end} = this.props;
  293. this.setState(state => {
  294. const utc = !state.utc;
  295. if (!start) {
  296. start = getDateWithTimezoneInUtc(state.start, state.utc);
  297. }
  298. if (!end) {
  299. end = getDateWithTimezoneInUtc(state.end, state.utc);
  300. }
  301. analytics('dateselector.utc_changed', {
  302. utc,
  303. path: getRouteStringFromRoutes(router.routes),
  304. org_id: parseInt(this.props.organization.id, 10),
  305. });
  306. const newDateTime = {
  307. relative: null,
  308. start: utc ? getLocalToSystem(start) : getUtcToSystem(start),
  309. end: utc ? getLocalToSystem(end) : getUtcToSystem(end),
  310. utc,
  311. };
  312. this.callCallback(onChange, newDateTime);
  313. return {
  314. hasChanges: true,
  315. ...newDateTime,
  316. };
  317. });
  318. };
  319. handleOpen = () => {
  320. this.setState({isOpen: true});
  321. // Start loading react-date-picker
  322. import('../timeRangeSelector/dateRange/index');
  323. };
  324. render() {
  325. const {
  326. defaultPeriod,
  327. showAbsolute,
  328. showRelative,
  329. organization,
  330. hint,
  331. label,
  332. relativeOptions,
  333. maxPickableDays,
  334. customDropdownButton,
  335. detached,
  336. alignDropdown,
  337. showPin,
  338. } = this.props;
  339. const {start, end, relative} = this.state;
  340. const shouldShowAbsolute = showAbsolute;
  341. const shouldShowRelative = showRelative;
  342. const isAbsoluteSelected = !!start && !!end;
  343. const summary =
  344. isAbsoluteSelected && start && end ? (
  345. <DateSummary start={start} end={end} />
  346. ) : (
  347. getRelativeSummary(
  348. relative || defaultPeriod || DEFAULT_STATS_PERIOD,
  349. relativeOptions
  350. )
  351. );
  352. return (
  353. <SelectorItemsHook
  354. shouldShowAbsolute={shouldShowAbsolute}
  355. shouldShowRelative={shouldShowRelative}
  356. relativePeriods={relativeOptions}
  357. handleSelectRelative={this.handleSelectRelative}
  358. >
  359. {items => (
  360. <ClassNames>
  361. {({css}) => (
  362. <StyledDropdownAutoComplete
  363. allowActorToggle
  364. alignMenu={alignDropdown ?? (isAbsoluteSelected ? 'right' : 'left')}
  365. isOpen={this.state.isOpen}
  366. onOpen={this.handleOpen}
  367. onClose={this.handleCloseMenu}
  368. hideInput={!shouldShowRelative}
  369. closeOnSelect={false}
  370. blendCorner={false}
  371. maxHeight={400}
  372. detached={detached}
  373. items={items}
  374. searchPlaceholder={t('Filter time range')}
  375. rootClassName={css`
  376. position: relative;
  377. display: flex;
  378. height: 100%;
  379. `}
  380. inputActions={
  381. showPin ? (
  382. <StyledPinButton size="xsmall" filter="datetime" />
  383. ) : undefined
  384. }
  385. onSelect={this.handleSelect}
  386. subPanel={
  387. isAbsoluteSelected && (
  388. <AbsoluteRangeWrap>
  389. <DateRangeHook
  390. start={start ?? null}
  391. end={end ?? null}
  392. organization={organization}
  393. showTimePicker
  394. utc={this.state.utc}
  395. onChange={this.handleSelectDateRange}
  396. onChangeUtc={this.handleUseUtc}
  397. maxPickableDays={maxPickableDays}
  398. />
  399. <SubmitRow>
  400. <MultipleSelectorSubmitRow
  401. onSubmit={this.handleCloseMenu}
  402. disabled={
  403. !this.state.hasChanges || this.state.hasDateRangeErrors
  404. }
  405. />
  406. </SubmitRow>
  407. </AbsoluteRangeWrap>
  408. )
  409. }
  410. >
  411. {({isOpen, getActorProps}) =>
  412. customDropdownButton ? (
  413. customDropdownButton({getActorProps, isOpen})
  414. ) : (
  415. <StyledHeaderItem
  416. data-test-id="page-filter-timerange-selector"
  417. icon={label ?? <IconCalendar />}
  418. isOpen={isOpen}
  419. hasSelected={
  420. (!!this.props.relative &&
  421. this.props.relative !== defaultPeriod) ||
  422. isAbsoluteSelected
  423. }
  424. hasChanges={this.state.hasChanges}
  425. onClear={this.handleClear}
  426. allowClear
  427. hint={hint}
  428. {...getActorProps()}
  429. >
  430. {getDynamicText({
  431. value: summary,
  432. fixed: 'start to end',
  433. })}
  434. </StyledHeaderItem>
  435. )
  436. }
  437. </StyledDropdownAutoComplete>
  438. )}
  439. </ClassNames>
  440. )}
  441. </SelectorItemsHook>
  442. );
  443. }
  444. }
  445. const TimeRangeRoot = styled('div')`
  446. position: relative;
  447. `;
  448. const StyledDropdownAutoComplete = styled(DropdownAutoComplete)`
  449. font-size: ${p => p.theme.fontSizeMedium};
  450. position: absolute;
  451. top: 100%;
  452. ${p =>
  453. !p.detached &&
  454. `
  455. margin-top: 0;
  456. border-radius: ${p.theme.borderRadiusBottom};
  457. `};
  458. `;
  459. const StyledHeaderItem = styled(HeaderItem)`
  460. height: 100%;
  461. `;
  462. const AbsoluteRangeWrap = styled('div')`
  463. display: flex;
  464. flex-direction: column;
  465. `;
  466. const SubmitRow = styled('div')`
  467. height: 100%;
  468. padding: ${space(0.5)} ${space(1)};
  469. border-top: 1px solid ${p => p.theme.innerBorder};
  470. border-left: 1px solid ${p => p.theme.border};
  471. `;
  472. const StyledPinButton = styled(PageFilterPinButton)`
  473. margin: 0 ${space(1)};
  474. `;
  475. export default withRouter(TimeRangeSelector);
  476. export {TimeRangeRoot};