searchBarDatePicker.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import {useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import format from 'date-fns/format';
  4. import type {Moment} from 'moment';
  5. import {Overlay} from 'sentry/components/overlay';
  6. import {t} from 'sentry/locale';
  7. import space from 'sentry/styles/space';
  8. import {DEFAULT_DAY_START_TIME, setDateToTime} from 'sentry/utils/dates';
  9. import {DatePicker} from '../calendar';
  10. import Checkbox from '../checkbox';
  11. type SearchBarDatePickerProps = {
  12. handleSelectDateTime: (value: string) => void;
  13. date?: Moment;
  14. dateString?: string;
  15. };
  16. type TimeInputProps = {
  17. setTime: (newTime: string) => void;
  18. time: string;
  19. };
  20. const TZ_OFFSET_REGEX = /[+-]\d\d:\d\d$/;
  21. const TIME_REGEX = /T\d\d:\d\d:\d\d/;
  22. const ISO_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
  23. const ISO_FORMAT_WITH_TIMEZONE = ISO_FORMAT + 'xxx';
  24. const isUtcIsoDate = (isoDateTime?: string) => {
  25. if (!isoDateTime) {
  26. return false;
  27. }
  28. return !TZ_OFFSET_REGEX.test(isoDateTime);
  29. };
  30. const applyChanges = ({
  31. date = new Date(),
  32. timeString = DEFAULT_DAY_START_TIME,
  33. handleSelectDateTime,
  34. utc = false,
  35. }: {
  36. handleSelectDateTime: (isoDateString: string) => void;
  37. date?: Date;
  38. timeString?: string;
  39. utc?: boolean;
  40. }) => {
  41. const newDate = setDateToTime(date, timeString, {local: true});
  42. handleSelectDateTime(format(newDate, utc ? ISO_FORMAT : ISO_FORMAT_WITH_TIMEZONE));
  43. };
  44. const parseIncomingDateString = (incomingDateString?: string) => {
  45. if (!incomingDateString) {
  46. return undefined;
  47. }
  48. // For consistent date parsing, remove timezone from the incoming date string
  49. const strippedTimeZone = incomingDateString
  50. .replace(TZ_OFFSET_REGEX, '')
  51. .replace(/Z$/, '');
  52. if (TIME_REGEX.test(incomingDateString)) {
  53. return new Date(strippedTimeZone);
  54. }
  55. return new Date(strippedTimeZone + 'T00:00:00');
  56. };
  57. const SearchBarDatePicker = ({
  58. dateString,
  59. handleSelectDateTime,
  60. }: SearchBarDatePickerProps) => {
  61. const incomingDate = parseIncomingDateString(dateString);
  62. const time = incomingDate ? format(incomingDate, 'HH:mm:ss') : DEFAULT_DAY_START_TIME;
  63. const utc = isUtcIsoDate(dateString);
  64. return (
  65. <SearchBarDatePickerOverlay
  66. onMouseDown={e => e.stopPropagation()}
  67. data-test-id="search-bar-date-picker"
  68. >
  69. <DatePicker
  70. date={incomingDate}
  71. onChange={newDate => {
  72. if (newDate instanceof Date) {
  73. applyChanges({
  74. date: newDate,
  75. timeString: time,
  76. utc,
  77. handleSelectDateTime,
  78. });
  79. }
  80. }}
  81. />
  82. <DatePickerFooter>
  83. <TimeInput
  84. time={time}
  85. setTime={newTime => {
  86. applyChanges({
  87. date: incomingDate,
  88. timeString: newTime,
  89. utc,
  90. handleSelectDateTime,
  91. });
  92. }}
  93. />
  94. <UtcPickerLabel>
  95. {t('Use UTC')}
  96. <Checkbox
  97. onChange={e => {
  98. applyChanges({
  99. date: incomingDate,
  100. timeString: time,
  101. utc: e.target.checked,
  102. handleSelectDateTime,
  103. });
  104. }}
  105. checked={utc}
  106. />
  107. </UtcPickerLabel>
  108. </DatePickerFooter>
  109. </SearchBarDatePickerOverlay>
  110. );
  111. };
  112. /**
  113. * This component keeps track of its own state because updates bring focus
  114. * back to the search bar. We make sure to keep focus within the input
  115. * until the user is done making changes.
  116. */
  117. const TimeInput = ({time, setTime}: TimeInputProps) => {
  118. const [localTime, setLocalTime] = useState(time);
  119. const [isFocused, setIsFocused] = useState(false);
  120. const timeInputRef = useRef<HTMLInputElement | null>(null);
  121. useEffect(() => {
  122. setLocalTime(time);
  123. }, [time]);
  124. return (
  125. <Input
  126. ref={timeInputRef}
  127. aria-label="Time"
  128. type="time"
  129. data-test-id="search-bar-date-picker-time-input"
  130. onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
  131. const newStartTime = e.target.value || DEFAULT_DAY_START_TIME;
  132. setLocalTime(newStartTime);
  133. if (!isFocused) {
  134. setTime(newStartTime);
  135. }
  136. }}
  137. onBlur={() => {
  138. setIsFocused(false);
  139. setTime(localTime);
  140. }}
  141. onFocus={() => setIsFocused(true)}
  142. onKeyDown={e => {
  143. if (e.key === 'Enter') {
  144. timeInputRef.current?.blur();
  145. }
  146. }}
  147. onClick={e => {
  148. e.stopPropagation();
  149. }}
  150. value={localTime}
  151. step={1}
  152. />
  153. );
  154. };
  155. const SearchBarDatePickerOverlay = styled(Overlay)`
  156. position: absolute;
  157. top: 100%;
  158. left: -1px;
  159. overflow: hidden;
  160. margin-top: ${space(1)};
  161. `;
  162. const Input = styled('input')`
  163. border-radius: 4px;
  164. padding: 0 ${space(1)};
  165. background: ${p => p.theme.backgroundSecondary};
  166. border: 1px solid ${p => p.theme.border};
  167. color: ${p => p.theme.gray300};
  168. box-shadow: none;
  169. `;
  170. const DatePickerFooter = styled('div')`
  171. display: flex;
  172. align-items: center;
  173. justify-content: space-between;
  174. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  175. `;
  176. const UtcPickerLabel = styled('label')`
  177. color: ${p => p.theme.gray300};
  178. white-space: nowrap;
  179. display: flex;
  180. align-items: center;
  181. justify-content: flex-end;
  182. margin: 0;
  183. font-weight: normal;
  184. user-select: none;
  185. cursor: pointer;
  186. input {
  187. margin: 0 0 0 0.5em;
  188. cursor: pointer;
  189. }
  190. `;
  191. export default SearchBarDatePicker;