Browse Source

feat(query-builder): Add easy multi-select while holding ctrl/cmd (#76610)

This adds the "Hold Ctrl/⌘ to select multiple" text and behavior that
was in the Figma designs. You could already click the checkbox for this
behavior, this makes it possible to do with the keyboard now as well.
Malachi Willey 6 months ago
parent
commit
3a643a6a06

+ 49 - 0
static/app/components/searchQueryBuilder/index.spec.tsx

@@ -1524,6 +1524,55 @@ describe('SearchQueryBuilder', function () {
         expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
       });
 
+      it('keeps focus inside value when multi-selecting with ctrl+enter', async function () {
+        render(
+          <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
+        );
+
+        await userEvent.click(
+          screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
+        );
+
+        // Arrow down two places to "Chrome" option
+        await userEvent.keyboard('{ArrowDown}{ArrowDown}');
+        // Pressing ctrl+enter should toggle the option and keep focus inside the input
+        await userEvent.keyboard('{Control>}{Enter}');
+        expect(
+          await screen.findByRole('row', {name: 'browser.name:[firefox,Chrome]'})
+        ).toBeInTheDocument();
+        await waitFor(() => {
+          expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
+            'firefox,Chrome,'
+          );
+        });
+        expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
+      });
+
+      it('keeps focus inside value when multi-selecting with ctrl+click', async function () {
+        render(
+          <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
+        );
+
+        const user = userEvent.setup();
+
+        await user.click(
+          screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
+        );
+
+        // Clicking option while holding Ctrl should toggle the option and keep focus inside the input
+        await user.keyboard('{Control>}');
+        await user.click(screen.getByRole('option', {name: 'Chrome'}));
+        expect(
+          await screen.findByRole('row', {name: 'browser.name:[firefox,Chrome]'})
+        ).toBeInTheDocument();
+        await waitFor(() => {
+          expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
+            'firefox,Chrome,'
+          );
+        });
+        expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
+      });
+
       it('collapses many selected options', function () {
         render(
           <SearchQueryBuilder

+ 2 - 0
static/app/components/searchQueryBuilder/tokens/combobox.tsx

@@ -110,6 +110,7 @@ type SearchQueryBuilderComboboxProps<T extends SelectOptionOrSectionWithKey<stri
 type OverlayProps = ReturnType<typeof useOverlay>['overlayProps'];
 
 export type CustomComboboxMenuProps<T> = {
+  filterValue: string;
   hiddenOptions: Set<SelectKey>;
   isOpen: boolean;
   listBoxProps: AriaListBoxOptions<T>;
@@ -277,6 +278,7 @@ function OverlayContent<T extends SelectOptionOrSectionWithKey<string>>({
       listBoxProps,
       state,
       overlayProps,
+      filterValue,
     });
   }
 

+ 50 - 8
static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx

@@ -1,5 +1,6 @@
 import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
 import styled from '@emotion/styled';
+import {isMac} from '@react-aria/utils';
 import {Item, Section} from '@react-stately/collections';
 import type {KeyboardEvent} from '@react-types/shared';
 
@@ -19,6 +20,7 @@ import {
   replaceCommaSeparatedValue,
   unescapeTagValue,
 } from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
+import {ValueListBox} from 'sentry/components/searchQueryBuilder/tokens/filter/valueListBox';
 import {getDefaultAbsoluteDateValue} from 'sentry/components/searchQueryBuilder/tokens/filter/valueSuggestions/date';
 import type {
   SuggestionItem,
@@ -55,6 +57,7 @@ import {type FieldDefinition, FieldValueType} from 'sentry/utils/fields';
 import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed';
 import {type QueryKey, useQuery} from 'sentry/utils/queryClient';
 import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
+import useKeyPress from 'sentry/utils/useKeyPress';
 import useOrganization from 'sentry/utils/useOrganization';
 
 type SearchQueryValueBuilderProps = {
@@ -258,7 +261,9 @@ function useFilterSuggestions({
   token,
   filterValue,
   selectedValues,
+  ctrlKeyPressed,
 }: {
+  ctrlKeyPressed: boolean;
   filterValue: string;
   selectedValues: string[];
   token: TokenResult<Token.FILTER>;
@@ -320,12 +325,13 @@ function useFilterSuggestions({
               token={token}
               disabled={disabled}
               value={suggestion.value}
+              ctrlKeyPressed={ctrlKeyPressed}
             />
           );
         },
       };
     },
-    [canSelectMultipleValues, token]
+    [canSelectMultipleValues, token, ctrlKeyPressed]
   );
 
   const suggestionGroups: SuggestionSection[] = useMemo(() => {
@@ -379,7 +385,9 @@ function ItemCheckbox({
   selected,
   disabled,
   value,
+  ctrlKeyPressed,
 }: {
+  ctrlKeyPressed: boolean;
   disabled: boolean;
   isFocused: boolean;
   selected: boolean;
@@ -394,7 +402,7 @@ function ItemCheckbox({
       onMouseUp={e => e.stopPropagation()}
       onClick={e => e.stopPropagation()}
     >
-      <CheckWrap visible={isFocused || selected} role="presentation">
+      <CheckWrap visible={isFocused || selected || ctrlKeyPressed} role="presentation">
         <Checkbox
           size="sm"
           checked={selected}
@@ -436,8 +444,14 @@ export function SearchQueryBuilderValueCombobox({
   const ref = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
   const organization = useOrganization();
-  const {getFieldDefinition, filterKeys, dispatch, searchSource, recentSearches} =
-    useSearchQueryBuilder();
+  const {
+    getFieldDefinition,
+    filterKeys,
+    dispatch,
+    searchSource,
+    recentSearches,
+    wrapperRef: topLevelWrapperRef,
+  } = useSearchQueryBuilder();
   const keyName = getKeyName(token.key);
   const fieldDefinition = getFieldDefinition(keyName);
   const canSelectMultipleValues = tokenSupportsMultipleValues(
@@ -470,6 +484,11 @@ export function SearchQueryBuilderValueCombobox({
     [canSelectMultipleValues, inputValue]
   );
 
+  const ctrlKeyPressed = useKeyPress(
+    isMac() ? 'Meta' : 'Control',
+    topLevelWrapperRef.current
+  );
+
   useEffect(() => {
     if (canSelectMultipleValues) {
       setInputValue(getMultiSelectInputValue(token));
@@ -489,6 +508,7 @@ export function SearchQueryBuilderValueCombobox({
     token,
     filterValue,
     selectedValues,
+    ctrlKeyPressed,
   });
 
   const analyticsData = useMemo(
@@ -533,7 +553,7 @@ export function SearchQueryBuilderValueCombobox({
             value: newValue,
           });
 
-          if (newValue && newValue !== '""') {
+          if (newValue && newValue !== '""' && !ctrlKeyPressed) {
             onCommit();
           }
 
@@ -548,7 +568,10 @@ export function SearchQueryBuilderValueCombobox({
             replaceCommaSeparatedValue(inputValue, selectionIndex, value)
           ),
         });
-        onCommit();
+
+        if (!ctrlKeyPressed) {
+          onCommit();
+        }
       } else {
         dispatch({
           type: 'UPDATE_TOKEN_VALUE',
@@ -570,6 +593,7 @@ export function SearchQueryBuilderValueCombobox({
       selectedValues,
       selectionIndex,
       token,
+      ctrlKeyPressed,
     ]
   );
 
@@ -690,7 +714,16 @@ export function SearchQueryBuilderValueCombobox({
 
   const customMenu: CustomComboboxMenu<SelectOptionWithKey<string>> | undefined =
     useMemo(() => {
-      if (!showDatePicker) return undefined;
+      if (!showDatePicker)
+        return function (props) {
+          return (
+            <ValueListBox
+              {...props}
+              isMultiSelect={canSelectMultipleValues}
+              items={items}
+            />
+          );
+        };
 
       return function (props) {
         return (
@@ -722,7 +755,16 @@ export function SearchQueryBuilderValueCombobox({
           />
         );
       };
-    }, [analyticsData, dispatch, inputValue, onCommit, showDatePicker, token]);
+    }, [
+      showDatePicker,
+      canSelectMultipleValues,
+      items,
+      inputValue,
+      token,
+      analyticsData,
+      dispatch,
+      onCommit,
+    ]);
 
   return (
     <ValueEditing ref={ref} data-test-id="filter-value-editing">

+ 85 - 0
static/app/components/searchQueryBuilder/tokens/filter/valueListBox.tsx

@@ -0,0 +1,85 @@
+import styled from '@emotion/styled';
+import {isMac} from '@react-aria/utils';
+
+import {ListBox} from 'sentry/components/compactSelect/listBox';
+import type {SelectOptionOrSectionWithKey} from 'sentry/components/compactSelect/types';
+import {Overlay} from 'sentry/components/overlay';
+import type {CustomComboboxMenuProps} from 'sentry/components/searchQueryBuilder/tokens/combobox';
+import {itemIsSection} from 'sentry/components/searchQueryBuilder/tokens/utils';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+interface ValueListBoxProps<T> extends CustomComboboxMenuProps<T> {
+  isMultiSelect: boolean;
+  items: T[];
+}
+
+export function ValueListBox<T extends SelectOptionOrSectionWithKey<string>>({
+  hiddenOptions,
+  isOpen,
+  listBoxProps,
+  listBoxRef,
+  popoverRef,
+  state,
+  overlayProps,
+  filterValue,
+  isMultiSelect,
+  items,
+}: ValueListBoxProps<T>) {
+  const totalOptions = items.reduce(
+    (acc, item) => acc + (itemIsSection(item) ? item.options.length : 1),
+    0
+  );
+  const anyItemsShowing = totalOptions > hiddenOptions.size;
+
+  if (!isOpen || !anyItemsShowing) {
+    return null;
+  }
+
+  return (
+    <StyledPositionWrapper {...overlayProps} visible={isOpen}>
+      <SectionedOverlay ref={popoverRef}>
+        <StyledListBox
+          {...listBoxProps}
+          ref={listBoxRef}
+          listState={state}
+          hasSearch={!!filterValue}
+          hiddenOptions={hiddenOptions}
+          keyDownHandler={() => true}
+          overlayIsOpen={isOpen}
+          showSectionHeaders={!filterValue}
+          size="sm"
+          style={{maxWidth: overlayProps.style.maxWidth}}
+        />
+        {isMultiSelect ? (
+          <Label>{t('Hold %s to select multiple', isMac() ? '⌘' : 'Ctrl')}</Label>
+        ) : null}
+      </SectionedOverlay>
+    </StyledPositionWrapper>
+  );
+}
+
+const SectionedOverlay = styled(Overlay)`
+  display: grid;
+  grid-template-rows: 1fr auto;
+  overflow: hidden;
+  max-height: 300px;
+  width: min-content;
+`;
+
+const StyledListBox = styled(ListBox)`
+  width: min-content;
+  min-width: 200px;
+`;
+
+const StyledPositionWrapper = styled('div')<{visible?: boolean}>`
+  display: ${p => (p.visible ? 'block' : 'none')};
+  z-index: ${p => p.theme.zIndex.tooltip};
+`;
+
+const Label = styled('div')`
+  padding: ${space(1)} ${space(2)};
+  color: ${p => p.theme.subText};
+  border-top: 1px solid ${p => p.theme.innerBorder};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;

+ 1 - 1
static/app/utils/useKeyPress.tsx

@@ -5,7 +5,7 @@ import {useEffect, useState} from 'react';
  */
 const useKeyPress = (
   targetKey: string,
-  target?: HTMLElement,
+  target?: HTMLElement | null,
   captureAndStop: boolean = false
 ) => {
   const [keyPressed, setKeyPressed] = useState(false);