Browse Source

feat(query-builder): Add special rendering for parens (#71632)

Adds component for rendering paren tokens, allowing it to be added or
removed by typing (or clicking the delete button).

Note that some behavior doesn't work well yet, namely focus hopping when
typing `(` or `)` (currently it will go behind the token instead of
ahead like you would expect).
Malachi Willey 9 months ago
parent
commit
6be603030e

+ 39 - 4
static/app/components/searchQueryBuilder/index.spec.tsx

@@ -87,16 +87,22 @@ describe('SearchQueryBuilder', function () {
       await userEvent.click(screen.getByRole('button', {name: 'Switch to plain text'}));
 
       // No longer displays tokens, has an input instead
-      expect(
-        screen.queryByRole('row', {name: 'browser.name:firefox'})
-      ).not.toBeInTheDocument();
+      await waitFor(() => {
+        expect(
+          screen.queryByRole('row', {name: 'browser.name:firefox'})
+        ).not.toBeInTheDocument();
+      });
       expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox');
 
       // Switching back should restore the tokens
       await userEvent.click(
         screen.getByRole('button', {name: 'Switch to tokenized search'})
       );
-      expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
+      await waitFor(() => {
+        expect(
+          screen.getByRole('row', {name: 'browser.name:firefox'})
+        ).toBeInTheDocument();
+      });
     });
   });
 
@@ -306,6 +312,15 @@ describe('SearchQueryBuilder', function () {
         ).getByText('some" value')
       ).toBeInTheDocument();
     });
+
+    it('can remove parens by clicking the delete button', async function () {
+      render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
+
+      expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
+      await userEvent.click(screen.getByRole('gridcell', {name: 'Delete ('}));
+
+      expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
+    });
   });
 
   describe('new search tokens', function () {
@@ -392,6 +407,15 @@ describe('SearchQueryBuilder', function () {
       // Filter value should have focus
       expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
     });
+
+    it('can add parens by typing', async function () {
+      render(<SearchQueryBuilder {...defaultProps} />);
+
+      await userEvent.click(screen.getByRole('grid'));
+      await userEvent.keyboard('(');
+
+      expect(await screen.findByRole('row', {name: '('})).toBeInTheDocument();
+    });
   });
 
   describe('keyboard interactions', function () {
@@ -508,5 +532,16 @@ describe('SearchQueryBuilder', function () {
       await userEvent.keyboard('{Shift>}{Tab}{/Shift}');
       expect(document.body).toHaveFocus();
     });
+
+    it('can remove parens with the keyboard', async function () {
+      render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
+
+      expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
+
+      await userEvent.click(screen.getByRole('grid'));
+      await userEvent.keyboard('{backspace}{backspace}');
+
+      expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
+    });
   });
 });

+ 1 - 1
static/app/components/searchQueryBuilder/index.tsx

@@ -86,7 +86,7 @@ export function SearchQueryBuilder({
   );
 
   const parsedQuery = useMemo(
-    () => collapseTextTokens(parseSearch(state.query || ' ')),
+    () => collapseTextTokens(parseSearch(state.query || ' ', {flattenParenGroups: true})),
     [state.query]
   );
 

+ 9 - 19
static/app/components/searchQueryBuilder/input.tsx

@@ -1,6 +1,5 @@
 import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
 import styled from '@emotion/styled';
-import {getFocusableTreeWalker} from '@react-aria/focus';
 import {mergeProps} from '@react-aria/utils';
 import {Item, Section} from '@react-stately/collections';
 import type {ListState} from '@react-stately/list';
@@ -11,6 +10,7 @@ import {getEscapedKey} from 'sentry/components/compactSelect/utils';
 import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/combobox';
 import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
 import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/useQueryBuilderGridItem';
+import {useShiftFocusToChild} from 'sentry/components/searchQueryBuilder/utils';
 import type {
   ParseResultToken,
   Token,
@@ -227,7 +227,11 @@ function SearchQueryBuilderInputInternal({
       token={token}
       inputLabel={t('Add a search term')}
       onInputChange={e => {
-        if (e.target.value.includes(':')) {
+        if (
+          e.target.value.includes(':') ||
+          e.target.value.includes('(') ||
+          e.target.value.includes(')')
+        ) {
           dispatch({type: 'UPDATE_FREE_TEXT', token, text: e.target.value});
           resetInputValue();
         } else {
@@ -260,26 +264,12 @@ export function SearchQueryBuilderInput({
   const ref = useRef<HTMLDivElement>(null);
 
   const {rowProps, gridCellProps} = useQueryBuilderGridItem(item, state, ref);
-
-  const onFocus = useCallback(
-    (e: React.FocusEvent<HTMLDivElement, Element>) => {
-      // Ensure that the state is updated correctly
-      state.selectionManager.setFocusedKey(item.key);
-
-      // When this row gains focus, immediately shift focus to the input
-      const walker = getFocusableTreeWalker(e.currentTarget);
-      const nextNode = walker.nextNode();
-      if (nextNode) {
-        (nextNode as HTMLElement).focus();
-      }
-    },
-    [item.key, state.selectionManager]
-  );
+  const {shiftFocusProps} = useShiftFocusToChild(item, state);
 
   const isFocused = item.key === state.selectionManager.focusedKey;
 
   return (
-    <Row {...mergeProps(rowProps, {onFocus})} ref={ref} tabIndex={-1}>
+    <Row {...mergeProps(rowProps, shiftFocusProps)} ref={ref} tabIndex={-1}>
       <GridCell {...gridCellProps} onClick={e => e.stopPropagation()}>
         <SearchQueryBuilderInputInternal
           item={item}
@@ -295,7 +285,7 @@ export function SearchQueryBuilderInput({
 const Row = styled('div')`
   display: flex;
   align-items: stretch;
-  height: 22px;
+  height: 24px;
 `;
 
 const GridCell = styled('div')`

+ 147 - 0
static/app/components/searchQueryBuilder/paren.tsx

@@ -0,0 +1,147 @@
+import {useRef} from 'react';
+import styled from '@emotion/styled';
+import {mergeProps} from '@react-aria/utils';
+import type {ListState} from '@react-stately/list';
+import type {Node} from '@react-types/shared';
+
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
+import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/useQueryBuilderGridItem';
+import {
+  shiftFocusToChild,
+  useShiftFocusToChild,
+} from 'sentry/components/searchQueryBuilder/utils';
+import {
+  type ParseResultToken,
+  Token,
+  type TokenResult,
+} from 'sentry/components/searchSyntax/parser';
+import {IconClose} from 'sentry/icons';
+import {IconParenthesis} from 'sentry/icons/iconParenthesis';
+import {t} from 'sentry/locale';
+
+type SearchQueryBuilderParenProps = {
+  item: Node<ParseResultToken>;
+  state: ListState<ParseResultToken>;
+  token: TokenResult<Token.L_PAREN | Token.R_PAREN>;
+};
+
+export function SearchQueryBuilderParen({
+  item,
+  state,
+  token,
+}: SearchQueryBuilderParenProps) {
+  const ref = useRef<HTMLDivElement>(null);
+  const {dispatch} = useSearchQueryBuilder();
+  const {rowProps, gridCellProps} = useQueryBuilderGridItem(item, state, ref);
+  const {shiftFocusProps} = useShiftFocusToChild(item, state);
+
+  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
+    if (e.key === 'Backspace' || e.key === 'Delete') {
+      e.preventDefault();
+      e.stopPropagation();
+      dispatch({type: 'DELETE_TOKEN', token});
+    }
+  };
+
+  const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
+    e.stopPropagation();
+    shiftFocusToChild(e.currentTarget, item, state);
+  };
+
+  return (
+    <Wrapper {...mergeProps(rowProps, shiftFocusProps, {onKeyDown, onClick})} ref={ref}>
+      <IconParenthesis
+        side={token.type === Token.L_PAREN ? 'left' : 'right'}
+        height={26}
+      />
+      <HoverFocusBorder>
+        <FloatingCloseButton
+          {...gridCellProps}
+          tabIndex={-1}
+          aria-label={t('Delete %s', token.value)}
+          onClick={e => {
+            e.stopPropagation();
+            dispatch({type: 'DELETE_TOKEN', token});
+          }}
+        >
+          <InteractionStateLayer />
+          <IconClose legacySize="10px" />
+        </FloatingCloseButton>
+      </HoverFocusBorder>
+    </Wrapper>
+  );
+}
+
+const FloatingCloseButton = styled('button')`
+  background: ${p => p.theme.background};
+  outline: none;
+  user-select: none;
+  padding: 0;
+  border: none;
+  color: ${p => p.theme.subText};
+  border-radius: 2px 2px 0 0;
+  box-shadow: 0 0 0 1px ${p => p.theme.innerBorder};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: absolute;
+  top: -14px;
+  height: 14px;
+  width: 14px;
+
+  &:focus,
+  &:hover {
+    outline: none;
+    border: none;
+    background: ${p => p.theme.button.default.backgroundActive};
+  }
+
+  &:focus-visible {
+    box-shadow: 0 0 0 1px ${p => p.theme.innerBorder};
+  }
+`;
+
+const Wrapper = styled('div')`
+  position: relative;
+  height: 24px;
+  border-radius: 2px;
+  width: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &:focus {
+    outline: none;
+  }
+
+  /* Need to hide visually but keep focusable */
+  &:not(:hover):not(:focus-within) {
+    color: ${p => p.theme.subText};
+
+    ${FloatingCloseButton} {
+      clip: rect(0 0 0 0);
+      clip-path: inset(50%);
+      height: 1px;
+      overflow: hidden;
+      position: absolute;
+      white-space: nowrap;
+      width: 1px;
+    }
+  }
+`;
+
+const HoverFocusBorder = styled('div')`
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 14px;
+  height: 33px;
+  transform: translate(-50%, -50%);
+  border-radius: 0 0 2px 2px;
+
+  &:focus-within,
+  &:hover {
+    box-shadow: 0 0 0 1px ${p => p.theme.innerBorder};
+  }
+`;

+ 11 - 0
static/app/components/searchQueryBuilder/tokenizedQueryGrid.tsx

@@ -8,6 +8,7 @@ import type {CollectionChildren} from '@react-types/shared';
 import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
 import {SearchQueryBuilderFilter} from 'sentry/components/searchQueryBuilder/filter';
 import {SearchQueryBuilderInput} from 'sentry/components/searchQueryBuilder/input';
+import {SearchQueryBuilderParen} from 'sentry/components/searchQueryBuilder/paren';
 import {useQueryBuilderGrid} from 'sentry/components/searchQueryBuilder/useQueryBuilderGrid';
 import {makeTokenKey} from 'sentry/components/searchQueryBuilder/utils';
 import {type ParseResultToken, Token} from 'sentry/components/searchSyntax/parser';
@@ -53,6 +54,16 @@ function Grid(props: GridProps) {
                 state={state}
               />
             );
+          case Token.L_PAREN:
+          case Token.R_PAREN:
+            return (
+              <SearchQueryBuilderParen
+                key={item.key}
+                token={token}
+                item={item}
+                state={state}
+              />
+            );
           // TODO(malwilley): Add other token types
           default:
             return null;

+ 37 - 0
static/app/components/searchQueryBuilder/utils.tsx

@@ -1,3 +1,8 @@
+import {useCallback} from 'react';
+import {getFocusableTreeWalker} from '@react-aria/focus';
+import type {ListState} from '@react-stately/list';
+import type {Node} from '@react-types/shared';
+
 import {
   filterTypeConfig,
   interchangeableFilterOperators,
@@ -109,3 +114,35 @@ export function formatFilterValue(token: TokenResult<Token.FILTER>['value']): st
       return token.text;
   }
 }
+
+export function shiftFocusToChild(
+  element: HTMLElement,
+  item: Node<ParseResultToken>,
+  state: ListState<ParseResultToken>
+) {
+  // Ensure that the state is updated correctly
+  state.selectionManager.setFocusedKey(item.key);
+
+  // When this row gains focus, immediately shift focus to the input
+  const walker = getFocusableTreeWalker(element);
+  const nextNode = walker.nextNode();
+  if (nextNode) {
+    (nextNode as HTMLElement).focus();
+  }
+}
+
+export function useShiftFocusToChild(
+  item: Node<ParseResultToken>,
+  state: ListState<ParseResultToken>
+) {
+  const onFocus = useCallback(
+    (e: React.FocusEvent<HTMLDivElement, Element>) => {
+      shiftFocusToChild(e.currentTarget, item, state);
+    },
+    [item, state]
+  );
+
+  return {
+    shiftFocusProps: {onFocus},
+  };
+}

+ 35 - 0
static/app/icons/iconParenthesis.tsx

@@ -0,0 +1,35 @@
+import {forwardRef} from 'react';
+import styled from '@emotion/styled';
+
+import type {SVGIconProps} from './svgIcon';
+
+interface Props extends SVGIconProps {
+  side?: 'left' | 'right';
+}
+
+const IconParenthesis = forwardRef<SVGSVGElement, Props>(
+  ({side = 'left', ...props}, ref) => {
+    return (
+      <StyledIcon
+        ref={ref}
+        data-test-id="icon-parenthesis"
+        viewBox="0 0 5 26"
+        data-paren-side={side}
+        fill={props.color ?? 'currentColor'}
+        {...props}
+      >
+        <path d="M0.912109 12.9542C0.912109 12.4473 0.955078 4.60684 1.04102 4.15748C1.12695 3.70453 1.24219 3.28572 1.38672 2.90107C1.53516 2.52361 1.70508 2.1767 1.89648 1.86035C2.0918 1.544 2.29688 1.2636 2.51172 1.01915C2.72266 0.774698 2.93945 0.567992 3.16211 0.399032C3.38477 0.226478 3.59961 0.093467 3.80664 0L4.08789 0.749533C3.96289 0.835811 3.83789 0.940062 3.71289 1.06229C3.58789 1.18451 3.46484 1.32292 3.34375 1.4775C3.17578 1.69679 3.01758 1.95382 2.86914 2.2486C2.72461 2.54338 2.59961 2.86512 2.49414 3.21383C2.39648 3.54096 2.31836 3.90225 2.25977 4.29768C2.20508 4.69312 2.17773 12.4832 2.17773 12.9434V13.0566C2.17773 13.506 2.20508 21.2871 2.25977 21.6754C2.31445 22.06 2.37891 22.3871 2.45312 22.6568C2.54688 22.9803 2.6543 23.2805 2.77539 23.5573C2.90039 23.8341 3.03125 24.0785 3.16797 24.2906C3.3125 24.5063 3.46289 24.6969 3.61914 24.8622C3.77539 25.0276 3.93164 25.166 4.08789 25.2774L3.80664 26C3.59961 25.9065 3.38477 25.7735 3.16211 25.601C2.93945 25.432 2.7207 25.2253 2.50586 24.9809C2.29102 24.7364 2.08594 24.456 1.89062 24.1396C1.69922 23.8269 1.53125 23.4782 1.38672 23.0935C1.24219 22.7125 1.12695 22.2991 1.04102 21.8533C0.955078 21.4039 0.912109 13.5599 0.912109 13.0458V12.9542Z" />
+      </StyledIcon>
+    );
+  }
+);
+
+const StyledIcon = styled('svg')`
+  &[data-paren-side='right'] {
+    transform: rotate(180deg);
+  }
+`;
+
+IconParenthesis.displayName = 'IconParenthesis';
+
+export {IconParenthesis};