Browse Source

feat(ui): Allow user to select arbitrary relative time ranges in TimeRangeSelector (#36624)

Malachi Willey 2 years ago
parent
commit
cc3acd5dc1

+ 8 - 2
static/app/components/dropdownAutoComplete/menu.tsx

@@ -9,7 +9,7 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 
-import autoCompleteFilter from './autoCompleteFilter';
+import defaultAutoCompleteFilter from './autoCompleteFilter';
 import List from './list';
 import {Item, ItemsBeforeFilter} from './types';
 
@@ -36,6 +36,11 @@ type Props = {
    * Dropdown menu alignment.
    */
   alignMenu?: 'left' | 'right';
+  /**
+   * Optionally provide a custom implementation for filtering result items
+   * Useful if you want to show items that don't strictly match the input value
+   */
+  autoCompleteFilter?: typeof defaultAutoCompleteFilter;
   /**
    * Should menu visually lock to a direction (so we don't display a rounded corner)
    */
@@ -201,6 +206,7 @@ type Props = {
 >;
 
 function Menu({
+  autoCompleteFilter = defaultAutoCompleteFilter,
   maxHeight = 300,
   emptyMessage = t('No items'),
   searchPlaceholder = t('Filter search'),
@@ -254,7 +260,7 @@ function Menu({
   // This avoids producing a new array on every call.
   const stableItemFilter = useCallback(
     (filterValueOrInput: string) => autoCompleteFilter(items, filterValueOrInput),
-    [items]
+    [autoCompleteFilter, items]
   );
 
   // Memoize the filterValueOrInput to the stableItemFilter so that we get the

+ 3 - 1
static/app/components/organizations/timeRangeSelector/index.tsx

@@ -34,6 +34,7 @@ import getDynamicText from 'sentry/utils/getDynamicText';
 import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
 
 import SelectorItems from './selectorItems';
+import timeRangeAutoCompleteFilter from './timeRangeAutoCompleteFilter';
 
 const DateRangeHook = HookOrDefault({
   hookName: 'component:header-date-range',
@@ -437,6 +438,7 @@ class TimeRangeSelector extends PureComponent<Props, State> {
             {({css}) => (
               <StyledDropdownAutoComplete
                 allowActorToggle
+                autoCompleteFilter={timeRangeAutoCompleteFilter}
                 alignMenu={alignDropdown ?? (isAbsoluteSelected ? 'right' : 'left')}
                 isOpen={this.state.isOpen}
                 inputValue={this.state.inputValue}
@@ -449,7 +451,7 @@ class TimeRangeSelector extends PureComponent<Props, State> {
                 maxHeight={400}
                 detached={detached}
                 items={items}
-                searchPlaceholder={t('Filter time range')}
+                searchPlaceholder={t('Provide a time range')}
                 rootClassName={css`
                   position: relative;
                   display: flex;

+ 4 - 11
static/app/components/organizations/timeRangeSelector/selectorItems.tsx

@@ -1,9 +1,8 @@
-import styled from '@emotion/styled';
-
 import {Item} from 'sentry/components/dropdownAutoComplete/types';
 import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
 import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
+
+import TimeRangeItemLabel from './timeRangeItemLabel';
 
 type Props = {
   children: (items: Item[]) => React.ReactElement;
@@ -27,7 +26,7 @@ const SelectorItems = ({
           index,
           value,
           searchKey: typeof itemLabel === 'string' ? itemLabel : value,
-          label: <Label>{itemLabel}</Label>,
+          label: <TimeRangeItemLabel>{itemLabel}</TimeRangeItemLabel>,
           'data-test-id': value,
         }))
       : []),
@@ -37,7 +36,7 @@ const SelectorItems = ({
             index: relativeArr.length,
             value: 'absolute',
             searchKey: 'absolute',
-            label: <Label>{t('Absolute date')}</Label>,
+            label: <TimeRangeItemLabel>{t('Absolute date')}</TimeRangeItemLabel>,
             'data-test-id': 'absolute',
           },
         ]
@@ -47,10 +46,4 @@ const SelectorItems = ({
   return children(items);
 };
 
-const Label = styled('div')`
-  margin-left: ${space(0.5)};
-  margin-top: ${space(0.25)};
-  margin-bottom: ${space(0.25)};
-`;
-
 export default SelectorItems;

+ 110 - 0
static/app/components/organizations/timeRangeSelector/timeRangeAutoCompleteFilter.tsx

@@ -0,0 +1,110 @@
+import autoCompleteFilter from 'sentry/components/dropdownAutoComplete/autoCompleteFilter';
+import {ItemsAfterFilter} from 'sentry/components/dropdownAutoComplete/types';
+import {t, tn} from 'sentry/locale';
+
+import TimeRangeItemLabel from './timeRangeItemLabel';
+
+const SUPPORTED_RELATIVE_PERIOD_UNITS = {
+  s: {
+    label: (num: number) => tn('Last second', 'Last %s seconds', num),
+    searchKey: t('seconds'),
+  },
+  m: {
+    label: (num: number) => tn('Last minute', 'Last %s minutes', num),
+    searchKey: t('minutes'),
+  },
+  h: {
+    label: (num: number) => tn('Last hour', 'Last %s hours', num),
+    searchKey: t('hours'),
+  },
+  d: {
+    label: (num: number) => tn('Last day', 'Last %s days', num),
+    searchKey: t('days'),
+  },
+  w: {
+    label: (num: number) => tn('Last week', 'Last %s weeks', num),
+    searchKey: t('weeks'),
+  },
+};
+
+const SUPPORTED_RELATIVE_UNITS_LIST = Object.keys(
+  SUPPORTED_RELATIVE_PERIOD_UNITS
+) as Array<keyof typeof SUPPORTED_RELATIVE_PERIOD_UNITS>;
+
+function makeItem(
+  amount: number,
+  unit: keyof typeof SUPPORTED_RELATIVE_PERIOD_UNITS,
+  index: number
+) {
+  return {
+    value: `${amount}${unit}`,
+    ['data-test-id']: `${amount}${unit}`,
+    label: (
+      <TimeRangeItemLabel>
+        {SUPPORTED_RELATIVE_PERIOD_UNITS[unit].label(amount)}
+      </TimeRangeItemLabel>
+    ),
+    searchKey: `${amount}${unit}`,
+    index,
+  };
+}
+
+/**
+ * A custom autocomplete implementation for <TimeRangeSelector />
+ * This function generates relative time ranges based on the user's input (not limited to those present in the initial set).
+ *
+ * When the user begins their input with a number, we provide all unit options for them to choose from:
+ * "5" => ["Last 5 seconds", "Last 5 minutes", "Last 5 hours", "Last 5 days", "Last 5 weeks"]
+ *
+ * When the user adds text after the number, we filter those options to the matching unit:
+ * "5d" => ["Last 5 days"]
+ * "5 days" => ["Last 5 days"]
+ *
+ * If the input does not begin with a number, we do a simple filter of the preset options.
+ */
+const timeRangeAutoCompleteFilter: typeof autoCompleteFilter = function (
+  items,
+  filterValue
+) {
+  if (!items) {
+    return [];
+  }
+
+  const match = filterValue.match(/(?<digits>\d+)\s*(?<string>\w*)/);
+
+  const userSuppliedAmount = Number(match?.groups?.digits);
+  const userSuppliedUnits = (match?.groups?.string ?? '').trim().toLowerCase();
+
+  const userSuppliedAmountIsValid = !isNaN(userSuppliedAmount) && userSuppliedAmount > 0;
+
+  // If there is a number w/o units, show all unit options
+  if (userSuppliedAmountIsValid && !userSuppliedUnits) {
+    return SUPPORTED_RELATIVE_UNITS_LIST.map((unit, index) =>
+      makeItem(userSuppliedAmount, unit, index)
+    );
+  }
+
+  // If there is a number followed by units, show the matching number/unit option
+  if (userSuppliedAmountIsValid && userSuppliedUnits) {
+    const matchingUnit = SUPPORTED_RELATIVE_UNITS_LIST.find(unit => {
+      if (userSuppliedUnits.length === 1) {
+        return unit === userSuppliedUnits;
+      }
+
+      return SUPPORTED_RELATIVE_PERIOD_UNITS[unit].searchKey.startsWith(
+        userSuppliedUnits
+      );
+    });
+
+    if (matchingUnit) {
+      return [makeItem(userSuppliedAmount, matchingUnit, 0)];
+    }
+  }
+
+  // Otherwise, do a normal filter search
+  return items
+    ?.filter(item => item.searchKey.toLowerCase().includes(filterValue.toLowerCase()))
+    .map((item, index) => ({...item, index})) as ItemsAfterFilter;
+};
+
+export default timeRangeAutoCompleteFilter;

+ 11 - 0
static/app/components/organizations/timeRangeSelector/timeRangeItemLabel.tsx

@@ -0,0 +1,11 @@
+import styled from '@emotion/styled';
+
+import space from 'sentry/styles/space';
+
+const TimeRangeItemLabel = styled('div')`
+  margin-left: ${space(0.5)};
+  margin-top: ${space(0.25)};
+  margin-bottom: ${space(0.25)};
+`;
+
+export default TimeRangeItemLabel;

+ 38 - 0
tests/js/spec/components/organizations/timeRangeSelector/index.spec.jsx

@@ -344,4 +344,42 @@ describe('TimeRangeSelector', function () {
     // On change should not be called because start/end did not change
     expect(onChange).not.toHaveBeenCalled();
   });
+
+  it('can select arbitrary relative time ranges', () => {
+    renderComponent();
+
+    userEvent.click(screen.getByRole('button'));
+
+    const input = screen.getByRole('textbox');
+    userEvent.type(input, '5');
+
+    // With just the number "5", all unit options should be present
+    expect(screen.getByText('Last 5 seconds')).toBeInTheDocument();
+    expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
+    expect(screen.getByText('Last 5 hours')).toBeInTheDocument();
+    expect(screen.getByText('Last 5 days')).toBeInTheDocument();
+    expect(screen.getByText('Last 5 weeks')).toBeInTheDocument();
+
+    userEvent.type(input, 'd');
+
+    // With "5d", only "Last 5 days" should be shown
+    expect(screen.getByText('Last 5 days')).toBeInTheDocument();
+    expect(screen.queryByText('Last 5 seconds')).not.toBeInTheDocument();
+    expect(screen.queryByText('Last 5 minutes')).not.toBeInTheDocument();
+    expect(screen.queryByText('Last 5 hours')).not.toBeInTheDocument();
+    expect(screen.queryByText('Last 5 weeks')).not.toBeInTheDocument();
+
+    userEvent.type(input, 'ays');
+
+    // "5days" Should still show "Last 5 days" option
+    expect(screen.getByText('Last 5 days')).toBeInTheDocument();
+
+    userEvent.type(input, '{Enter}');
+
+    expect(onChange).toHaveBeenLastCalledWith({
+      relative: '5d',
+      start: undefined,
+      end: undefined,
+    });
+  });
 });