utils.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import moment from 'moment';
  2. import autoCompleteFilter from 'sentry/components/dropdownAutoComplete/autoCompleteFilter';
  3. import {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types';
  4. import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
  5. import {t, tn} from 'sentry/locale';
  6. import TimeRangeItemLabel from './timeRangeItemLabel';
  7. type PeriodUnit = 's' | 'm' | 'h' | 'd' | 'w';
  8. type RelativePeriodUnit = Exclude<PeriodUnit, 's'>;
  9. export type RelativeUnitsMapping = {
  10. [Unit: string]: {
  11. convertToDaysMultiplier: number;
  12. label: (num: number) => string;
  13. momentUnit: moment.unitOfTime.DurationConstructor;
  14. searchKey: string;
  15. };
  16. };
  17. const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss';
  18. const STATS_PERIOD_REGEX = /^(\d+)([smhdw]{1})$/;
  19. const SUPPORTED_RELATIVE_PERIOD_UNITS: RelativeUnitsMapping = {
  20. m: {
  21. label: (num: number) => tn('Last minute', 'Last %s minutes', num),
  22. searchKey: t('minutes'),
  23. momentUnit: 'minutes',
  24. convertToDaysMultiplier: 1 / (60 * 24),
  25. },
  26. h: {
  27. label: (num: number) => tn('Last hour', 'Last %s hours', num),
  28. searchKey: t('hours'),
  29. momentUnit: 'hours',
  30. convertToDaysMultiplier: 1 / 24,
  31. },
  32. d: {
  33. label: (num: number) => tn('Last day', 'Last %s days', num),
  34. searchKey: t('days'),
  35. momentUnit: 'days',
  36. convertToDaysMultiplier: 1,
  37. },
  38. w: {
  39. label: (num: number) => tn('Last week', 'Last %s weeks', num),
  40. searchKey: t('weeks'),
  41. momentUnit: 'weeks',
  42. convertToDaysMultiplier: 7,
  43. },
  44. };
  45. const SUPPORTED_RELATIVE_UNITS_LIST = Object.keys(
  46. SUPPORTED_RELATIVE_PERIOD_UNITS
  47. ) as RelativePeriodUnit[];
  48. const parseStatsPeriodString = (statsPeriodString: string) => {
  49. const result = STATS_PERIOD_REGEX.exec(statsPeriodString);
  50. if (result === null) {
  51. throw new Error('Invalid stats period');
  52. }
  53. const value = parseInt(result[1], 10);
  54. const unit = result[2] as RelativePeriodUnit;
  55. return {
  56. value,
  57. unit,
  58. };
  59. };
  60. /**
  61. * Converts a relative stats period, e.g. `1h` to an object containing a start
  62. * and end date, with the end date as the current time and the start date as the
  63. * time that is the current time less the statsPeriod.
  64. *
  65. * @param statsPeriod Relative stats period
  66. * @param outputFormat Format of outputted start/end date
  67. * @return Object containing start and end date as YYYY-MM-DDTHH:mm:ss
  68. *
  69. */
  70. export function parseStatsPeriod(
  71. statsPeriod: string,
  72. outputFormat: string | null = DATE_TIME_FORMAT
  73. ): {end: string; start: string} {
  74. const {value, unit} = parseStatsPeriodString(statsPeriod);
  75. const momentUnit = SUPPORTED_RELATIVE_PERIOD_UNITS[unit].momentUnit;
  76. const format = outputFormat === null ? undefined : outputFormat;
  77. return {
  78. start: moment().subtract(value, momentUnit).format(format),
  79. end: moment().format(format),
  80. };
  81. }
  82. /**
  83. * Given a relative stats period, e.g. `1h`, return a pretty string if it
  84. * is a default stats period. Otherwise if it's a valid period (can be any number
  85. * followed by a single character s|m|h|d) display "Other" or "Invalid period" if invalid
  86. *
  87. * @param relative Relative stats period
  88. * @return either one of the default "Last x days" string, "Other" if period is valid on the backend, or "Invalid period" otherwise
  89. */
  90. export function getRelativeSummary(
  91. relative: string,
  92. relativeOptions?: Record<string, React.ReactNode>
  93. ): string {
  94. try {
  95. const defaultRelativePeriodString =
  96. relativeOptions?.[relative] ?? DEFAULT_RELATIVE_PERIODS[relative];
  97. if (defaultRelativePeriodString) {
  98. return defaultRelativePeriodString;
  99. }
  100. const {value, unit} = parseStatsPeriodString(relative);
  101. return SUPPORTED_RELATIVE_PERIOD_UNITS[unit].label(value);
  102. } catch {
  103. return 'Invalid period';
  104. }
  105. }
  106. export function makeItem(
  107. amount: number,
  108. unit: string,
  109. label: (num: number) => string,
  110. index: number
  111. ) {
  112. return {
  113. value: `${amount}${unit}`,
  114. ['data-test-id']: `${amount}${unit}`,
  115. label: <TimeRangeItemLabel>{label(amount)}</TimeRangeItemLabel>,
  116. searchKey: `${amount}${unit}`,
  117. index,
  118. };
  119. }
  120. function timePeriodIsWithinLimit<T extends RelativeUnitsMapping>({
  121. amount,
  122. unit,
  123. maxDays,
  124. supportedPeriods,
  125. }: {
  126. amount: number;
  127. supportedPeriods: T;
  128. unit: keyof T & string;
  129. maxDays?: number;
  130. }) {
  131. if (!maxDays) {
  132. return true;
  133. }
  134. const daysMultiplier = supportedPeriods[unit].convertToDaysMultiplier;
  135. return daysMultiplier * amount <= maxDays;
  136. }
  137. /**
  138. * A custom autocomplete implementation for <TimeRangeSelector />
  139. * This function generates relative time ranges based on the user's input (not limited to those present in the initial set).
  140. *
  141. * When the user begins their input with a number, we provide all unit options for them to choose from:
  142. * "5" => ["Last 5 seconds", "Last 5 minutes", "Last 5 hours", "Last 5 days", "Last 5 weeks"]
  143. *
  144. * When the user adds text after the number, we filter those options to the matching unit:
  145. * "5d" => ["Last 5 days"]
  146. * "5 days" => ["Last 5 days"]
  147. *
  148. * If the input does not begin with a number, we do a simple filter of the preset options.
  149. */
  150. export const _timeRangeAutoCompleteFilter = function <T extends RelativeUnitsMapping>(
  151. items: ItemsBeforeFilter | null,
  152. filterValue: string,
  153. {
  154. supportedPeriods,
  155. supportedUnits,
  156. maxDays,
  157. }: {
  158. supportedPeriods: T;
  159. supportedUnits: Array<keyof T & string>;
  160. maxDays?: number;
  161. }
  162. ): ReturnType<typeof autoCompleteFilter> {
  163. if (!items) {
  164. return [];
  165. }
  166. const match = filterValue.match(/(?<digits>\d+)\s*(?<string>\w*)/);
  167. const userSuppliedAmount = Number(match?.groups?.digits);
  168. const userSuppliedUnits = (match?.groups?.string ?? '').trim().toLowerCase();
  169. const userSuppliedAmountIsValid = !isNaN(userSuppliedAmount) && userSuppliedAmount > 0;
  170. // If there is a number w/o units, show all unit options within limit
  171. if (userSuppliedAmountIsValid && !userSuppliedUnits) {
  172. return supportedUnits
  173. .filter(unit =>
  174. timePeriodIsWithinLimit({
  175. amount: userSuppliedAmount,
  176. unit,
  177. maxDays,
  178. supportedPeriods,
  179. })
  180. )
  181. .map((unit, index) =>
  182. makeItem(userSuppliedAmount, unit, supportedPeriods[unit].label, index)
  183. );
  184. }
  185. // If there is a number followed by units, show the matching number/unit option
  186. if (userSuppliedAmountIsValid && userSuppliedUnits) {
  187. const matchingUnit = supportedUnits.find(unit => {
  188. if (userSuppliedUnits.length === 1) {
  189. return unit === userSuppliedUnits;
  190. }
  191. return supportedPeriods[unit].searchKey.startsWith(userSuppliedUnits);
  192. });
  193. if (
  194. matchingUnit &&
  195. timePeriodIsWithinLimit({
  196. amount: userSuppliedAmount,
  197. unit: matchingUnit,
  198. maxDays,
  199. supportedPeriods,
  200. })
  201. ) {
  202. return [
  203. makeItem(
  204. userSuppliedAmount,
  205. matchingUnit,
  206. supportedPeriods[matchingUnit].label,
  207. 0
  208. ),
  209. ];
  210. }
  211. }
  212. // Otherwise, do a normal filter search
  213. return autoCompleteFilter(items, filterValue);
  214. };
  215. export const timeRangeAutoCompleteFilter = function (
  216. items: ItemsBeforeFilter | null,
  217. filterValue: string,
  218. options: {
  219. maxDays?: number;
  220. supportedPeriods?: RelativeUnitsMapping;
  221. supportedUnits?: RelativePeriodUnit[];
  222. }
  223. ): ReturnType<typeof autoCompleteFilter> {
  224. return _timeRangeAutoCompleteFilter(items, filterValue, {
  225. supportedPeriods: SUPPORTED_RELATIVE_PERIOD_UNITS,
  226. supportedUnits: SUPPORTED_RELATIVE_UNITS_LIST,
  227. ...options,
  228. });
  229. };