Browse Source

feat(query-builder): Add ability to add text and new filter keys (#69542)

Closes https://github.com/getsentry/sentry/issues/69266

- Adds `<SearchQueryBuilderInput />` component which can be used to add
free text or new filter tokens
- Modifies the common combobox component to receive `filterValue`
separately from `inputValue` (this allows us to search the dropdown with
a segment of the entire input value)
- Added `UPDATE_FREE_TEXT` action to handle updates from the new input
component. This does a simple replace normally, but if a new filter key
was added it also update the focus to be within the new filter.
- Added `FOCUS_FREE_TEXT` to set focus inside the new input
- Edited the container component to combine adjacent free text and space
tokens so we can render a single input for each set of text
Malachi Willey 10 months ago
parent
commit
a3e50a45f7

+ 28 - 29
static/app/components/searchQueryBuilder/combobox.tsx

@@ -35,23 +35,29 @@ type SearchQueryBuilderComboboxProps = {
   inputLabel: string;
   inputValue: string;
   items: SelectOptionWithKey<string>[];
-  onChange: (key: string) => void;
+  onCustomValueSelected: (value: string) => void;
   onExit: () => void;
-  placeholder: string;
-  setInputValue: (value: string) => void;
-  token: TokenResult<Token.FILTER>;
+  onOptionSelected: (value: string) => void;
+  token: TokenResult<Token>;
+  filterValue?: string;
+  onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
+  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
+  placeholder?: string;
 };
 
 export function SearchQueryBuilderCombobox({
   children,
   items,
   inputValue,
-  setInputValue,
+  filterValue = inputValue,
   placeholder,
-  onChange,
+  onCustomValueSelected,
+  onOptionSelected,
   token,
   inputLabel,
   onExit,
+  onKeyDown,
+  onInputChange,
 }: SearchQueryBuilderComboboxProps) {
   const theme = useTheme();
   const listBoxRef = useRef<HTMLUListElement>(null);
@@ -67,8 +73,8 @@ export function SearchQueryBuilderCombobox({
   }, [focus, token]);
 
   const hiddenOptions = useMemo(() => {
-    return getHiddenOptions(items, inputValue, 10);
-  }, [items, inputValue]);
+    return getHiddenOptions(items, filterValue, 10);
+  }, [items, filterValue]);
 
   const disabledKeys = useMemo(
     () => [...getDisabledOptions(items), ...hiddenOptions].map(getEscapedKey),
@@ -79,25 +85,21 @@ export function SearchQueryBuilderCombobox({
     (key: Key) => {
       const selectedOption = items.find(item => item.key === key);
       if (selectedOption) {
-        onChange(selectedOption.textValue ?? '');
-      } else {
-        onChange(key.toString());
+        onOptionSelected(selectedOption.textValue ?? '');
+      } else if (key) {
+        onOptionSelected(key.toString());
       }
     },
-    [items, onChange]
+    [items, onOptionSelected]
   );
 
   const state = useComboBoxState<SelectOptionWithKey<string>>({
     children,
     items,
     autoFocus: true,
-    inputValue,
-    onInputChange: setInputValue,
+    inputValue: filterValue,
     onSelectionChange,
     disabledKeys,
-    onFocus: () => {
-      state.open();
-    },
   });
   const {inputProps, listBoxProps} = useComboBox<SelectOptionWithKey<string>>(
     {
@@ -106,22 +108,19 @@ export function SearchQueryBuilderCombobox({
       inputRef,
       popoverRef,
       items,
-      inputValue,
+      inputValue: filterValue,
       onSelectionChange,
-      onInputChange: setInputValue,
       autoFocus: true,
-      onFocus: () => {
-        state.open();
-      },
       onBlur: () => {
-        if (state.inputValue) {
-          onChange(state.inputValue);
+        if (inputValue) {
+          onCustomValueSelected(inputValue);
         } else {
           onExit();
         }
         state.close();
       },
       onKeyDown: e => {
+        onKeyDown?.(e);
         switch (e.key) {
           case 'Escape':
             state.close();
@@ -132,7 +131,7 @@ export function SearchQueryBuilderCombobox({
               return;
             }
             state.close();
-            onChange(state.inputValue);
+            onCustomValueSelected(inputValue);
             return;
           default:
             return;
@@ -153,7 +152,7 @@ export function SearchQueryBuilderCombobox({
     shouldCloseOnBlur: true,
     onInteractOutside: () => {
       if (state.inputValue) {
-        onChange(state.inputValue);
+        onCustomValueSelected(inputValue);
       } else {
         onExit();
       }
@@ -171,12 +170,12 @@ export function SearchQueryBuilderCombobox({
 
   const selectContextValue = useMemo(
     () => ({
-      search: inputValue,
+      search: filterValue,
       overlayIsOpen: isOpen,
       registerListState: () => {},
       saveSelectedOptions: () => {},
     }),
-    [inputValue, isOpen]
+    [filterValue, isOpen]
   );
 
   return (
@@ -191,7 +190,7 @@ export function SearchQueryBuilderCombobox({
             placeholder={placeholder}
             onClick={handleInputClick}
             value={inputValue}
-            onChange={e => setInputValue(e.target.value)}
+            onChange={onInputChange}
             autoFocus
           />
           <StyledPositionWrapper

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

@@ -104,6 +104,7 @@ describe('SearchQueryBuilder', function () {
       await userEvent.click(screen.getByRole('gridcell', {name: 'Edit token value'}));
       // Should have placeholder text of previous value
       expect(screen.getByRole('combobox')).toHaveAttribute('placeholder', 'firefox');
+      await userEvent.click(screen.getByRole('combobox'));
 
       // Clicking the "Chrome option should update the value"
       await userEvent.click(screen.getByRole('option', {name: 'Chrome'}));
@@ -134,4 +135,53 @@ describe('SearchQueryBuilder', function () {
       ).toBeInTheDocument();
     });
   });
+
+  describe('new search tokens', function () {
+    it('can add a new token by clicking a key suggestion', async function () {
+      render(<SearchQueryBuilder {...defaultProps} />);
+
+      await userEvent.click(
+        screen.getByRole('row', {name: 'Click to add a search term'})
+      );
+      await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
+      await userEvent.click(screen.getByRole('option', {name: 'Browser Name'}));
+
+      // New token should be added with the correct key
+      expect(screen.getByRole('row', {name: 'browser.name:'})).toBeInTheDocument();
+
+      await userEvent.click(screen.getByRole('combobox'));
+      await userEvent.click(screen.getByRole('option', {name: 'Firefox'}));
+
+      // New token should have a value
+      expect(screen.getByRole('row', {name: 'browser.name:Firefox'})).toBeInTheDocument();
+    });
+
+    it('can add free text by typing', async function () {
+      render(<SearchQueryBuilder {...defaultProps} />);
+
+      await userEvent.click(screen.getByRole('grid'));
+      await userEvent.type(screen.getByRole('combobox'), 'some free text{enter}');
+      expect(screen.getByRole('combobox')).toHaveValue('some free text');
+    });
+
+    it('can add a filter after some free text', async function () {
+      render(<SearchQueryBuilder {...defaultProps} />);
+
+      await userEvent.click(screen.getByRole('grid'));
+      await userEvent.type(
+        screen.getByRole('combobox'),
+        'some free text brow{ArrowDown}'
+      );
+      await userEvent.click(screen.getByRole('option', {name: 'Browser Name'}));
+
+      // Should have a free text token "some free text"
+      expect(screen.getByRole('row', {name: 'some free text'})).toBeInTheDocument();
+
+      // Should have a filter token with key "browser.name"
+      expect(screen.getByRole('row', {name: 'browser.name:'})).toBeInTheDocument();
+
+      // Filter value should have focus
+      expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
+    });
+  });
 });

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

@@ -9,7 +9,12 @@ import type {TagCollection} from 'sentry/types';
 import {FieldKey, FieldKind} from 'sentry/utils/fields';
 
 const SUPPORTED_KEYS: TagCollection = {
-  [FieldKey.AGE]: {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD},
+  [FieldKey.AGE]: {
+    key: FieldKey.AGE,
+    name: 'Age',
+    kind: FieldKind.FIELD,
+    predefined: true,
+  },
   [FieldKey.ASSIGNED]: {
     key: FieldKey.ASSIGNED,
     name: 'Assigned To',

+ 37 - 9
static/app/components/searchQueryBuilder/index.tsx

@@ -1,10 +1,15 @@
-import {useEffect, useMemo, useRef} from 'react';
+import {useEffect, useMemo, useRef, useState} from 'react';
 import styled from '@emotion/styled';
 
 import {inputStyles} from 'sentry/components/input';
 import {SearchQueryBuilerContext} from 'sentry/components/searchQueryBuilder/context';
 import {SearchQueryBuilderFilter} from 'sentry/components/searchQueryBuilder/filter';
+import {SearchQueryBuilderInput} from 'sentry/components/searchQueryBuilder/input';
 import {useQueryBuilderState} from 'sentry/components/searchQueryBuilder/useQueryBuilderState';
+import {
+  collapseTextTokens,
+  makeTokenKey,
+} from 'sentry/components/searchQueryBuilder/utils';
 import {parseSearch, Token} from 'sentry/components/searchSyntax/parser';
 import {IconSearch} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -27,9 +32,13 @@ export function SearchQueryBuilder({
   getTagValues,
   onChange,
 }: SearchQueryBuilderProps) {
+  const [hasFocus, setHasFocus] = useState(false);
   const {state, dispatch} = useQueryBuilderState({initialQuery});
 
-  const parsedQuery = useMemo(() => parseSearch(state.query), [state.query]);
+  const parsedQuery = useMemo(
+    () => collapseTextTokens(parseSearch(state.query || ' ')),
+    [state.query]
+  );
 
   useEffect(() => {
     onChange?.(state.query);
@@ -45,21 +54,40 @@ export function SearchQueryBuilder({
     };
   }, [state, parsedQuery, supportedKeys, getTagValues, dispatch]);
 
-  const ref = useRef(null);
+  const ref = useRef<HTMLDivElement>(null);
 
   return (
     <SearchQueryBuilerContext.Provider value={contextValue}>
-      <Wrapper ref={ref} role="grid" aria-label={label ?? t('Create a search query')}>
+      <Wrapper
+        ref={ref}
+        tabIndex={hasFocus ? -1 : 0}
+        role="grid"
+        aria-label={label ?? t('Create a search query')}
+        onBlur={e => {
+          if (!ref.current?.contains(e.relatedTarget as Node)) {
+            setHasFocus(false);
+          }
+        }}
+        onFocus={e => {
+          if (e.target === ref.current) {
+            dispatch({type: 'FOCUS_FREE_TEXT', cursor: state.query.length});
+          }
+
+          setHasFocus(true);
+        }}
+      >
         <PositionedSearchIcon size="sm" />
         <PanelProvider>
           {parsedQuery?.map(token => {
             switch (token?.type) {
               case Token.FILTER:
                 return (
-                  <SearchQueryBuilderFilter
-                    key={token.location.start.offset}
-                    token={token}
-                  />
+                  <SearchQueryBuilderFilter key={makeTokenKey(token)} token={token} />
+                );
+              case Token.SPACES:
+              case Token.FREE_TEXT:
+                return (
+                  <SearchQueryBuilderInput key={makeTokenKey(token)} token={token} />
                 );
               // TODO(malwilley): Add other token types
               default:
@@ -79,7 +107,7 @@ const Wrapper = styled('div')`
   position: relative;
 
   display: flex;
-  gap: ${space(1)};
+  gap: ${space(0.5)};
   flex-wrap: wrap;
   font-size: ${p => p.theme.fontSizeMedium};
   padding: ${space(0.75)} ${space(0.75)} ${space(0.75)} 36px;

+ 165 - 0
static/app/components/searchQueryBuilder/input.tsx

@@ -0,0 +1,165 @@
+import {useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import {Item, Section} from '@react-stately/collections';
+
+import {getItemsWithKeys} from 'sentry/components/compactSelect/utils';
+import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/combobox';
+import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
+import {focusIsWithinToken} from 'sentry/components/searchQueryBuilder/utils';
+import type {Token, TokenResult} from 'sentry/components/searchSyntax/parser';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {FieldKind, getFieldDefinition} from 'sentry/utils/fields';
+
+type SearchQueryBuilderInputProps = {
+  token: TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES>;
+};
+
+function getWordAtCursorPosition(value: string, cursorPosition: number) {
+  const words = value.split(' ');
+
+  let characterCount = 0;
+  for (const word of words) {
+    characterCount += word.length + 1;
+    if (characterCount >= cursorPosition) {
+      return word;
+    }
+  }
+
+  return value;
+}
+
+/**
+ * Replaces the focused word (at cursorPosition) with the selected filter key.
+ *
+ * Example:
+ * replaceFocusedWordWithFilter('before brow after', 9, 'browser.name') => 'before browser.name: after'
+ */
+function replaceFocusedWordWithFilter(
+  value: string,
+  cursorPosition: number,
+  key: string
+) {
+  const words = value.split(' ');
+
+  let characterCount = 0;
+  for (const word of words) {
+    characterCount += word.length + 1;
+    if (characterCount >= cursorPosition) {
+      return (
+        value.slice(0, characterCount - word.length - 1).trim() +
+        ` ${key}: ` +
+        value.slice(characterCount).trim()
+      ).trim();
+    }
+  }
+
+  return value;
+}
+
+export function SearchQueryBuilderInput({token}: SearchQueryBuilderInputProps) {
+  const [inputValue, setInputValue] = useState(token.value.trim());
+  // TODO(malwilley): Use input ref to update cursor position on mount
+  const [selectionIndex, setSelectionIndex] = useState(0);
+
+  const resetInputValue = () => {
+    setInputValue(token.value.trim());
+    // TODO(malwilley): Reset cursor position using ref
+  };
+
+  const filterValue = getWordAtCursorPosition(inputValue, selectionIndex);
+
+  const {keys, dispatch, focus} = useSearchQueryBuilder();
+
+  const allKeys = useMemo(() => {
+    return Object.values(keys);
+  }, [keys]);
+
+  const items = useMemo(() => {
+    return getItemsWithKeys(
+      allKeys.map(tag => {
+        const fieldDefinition = getFieldDefinition(tag.key);
+
+        return {
+          label: fieldDefinition?.kind === FieldKind.FIELD ? tag.name : tag.key,
+          value: tag.key,
+          textValue: tag.key,
+          hideCheck: true,
+        };
+      })
+    );
+  }, [allKeys]);
+
+  const isFocused = focusIsWithinToken(focus, token);
+
+  useEffect(() => {
+    setInputValue(token.value.trim());
+  }, [token.value]);
+
+  if (!isFocused) {
+    return (
+      <Inactive
+        tabIndex={-1}
+        role="row"
+        aria-label={inputValue || t('Click to add a search term')}
+        onClick={() =>
+          dispatch({type: 'FOCUS_FREE_TEXT', cursor: token.location.start.offset})
+        }
+      >
+        {inputValue}
+      </Inactive>
+    );
+  }
+
+  return (
+    <SearchQueryBuilderCombobox
+      items={items}
+      onOptionSelected={value => {
+        dispatch({
+          type: 'UPDATE_FREE_TEXT',
+          token,
+          text: replaceFocusedWordWithFilter(inputValue, selectionIndex, value),
+        });
+        resetInputValue();
+      }}
+      onCustomValueSelected={value => {
+        dispatch({type: 'UPDATE_FREE_TEXT', token, text: value});
+        resetInputValue();
+      }}
+      onExit={() => {
+        if (inputValue !== token.value.trim()) {
+          dispatch({type: 'UPDATE_FREE_TEXT', token, text: inputValue});
+          resetInputValue();
+        }
+      }}
+      inputValue={inputValue}
+      filterValue={filterValue}
+      token={token}
+      inputLabel={t('Add a search term')}
+      onInputChange={e => {
+        if (e.target.value.includes(':')) {
+          dispatch({type: 'UPDATE_FREE_TEXT', token, text: e.target.value});
+          resetInputValue();
+        } else {
+          setInputValue(e.target.value);
+          setSelectionIndex(e.target.selectionStart ?? 0);
+        }
+      }}
+    >
+      <Section>
+        {items.map(item => (
+          <Item {...item} key={item.key}>
+            {item.label}
+          </Item>
+        ))}
+      </Section>
+    </SearchQueryBuilderCombobox>
+  );
+}
+
+const Inactive = styled('div')`
+  display: flex;
+  align-items: center;
+  padding: 0 ${space(0.5)};
+  margin: 0 -${space(0.5)};
+`;

+ 0 - 0
static/app/components/searchQueryBuilder/keyCombobox.tsx


+ 10 - 1
static/app/components/searchQueryBuilder/types.tsx

@@ -2,6 +2,7 @@ export enum QueryBuilderFocusType {
   FILTER_VALUE = 'filter_value',
   FILTER_OP = 'filter_op',
   FILTER_DELETE = 'filter_delete',
+  TOKEN = 'token',
 }
 
 interface BaseTokenFocus {
@@ -25,4 +26,12 @@ interface FilterOpFocus extends BaseTokenFocus {
   type: QueryBuilderFocusType.FILTER_OP;
 }
 
-export type QueryBuilderFocusState = FilterValueFocus | FilterOpFocus | FilterDeleteFocus;
+interface TokenFocus extends BaseTokenFocus {
+  type: QueryBuilderFocusType.TOKEN;
+}
+
+export type QueryBuilderFocusState =
+  | FilterValueFocus
+  | FilterOpFocus
+  | FilterDeleteFocus
+  | TokenFocus;

+ 141 - 19
static/app/components/searchQueryBuilder/useQueryBuilderState.tsx

@@ -6,8 +6,9 @@ import {
 } from 'sentry/components/searchQueryBuilder/types';
 import {
   type ParseResultToken,
+  parseSearch,
   TermOperator,
-  type Token,
+  Token,
   type TokenResult,
 } from 'sentry/components/searchSyntax/parser';
 import {stringifyToken} from 'sentry/components/searchSyntax/utils';
@@ -22,6 +23,12 @@ type DeleteTokenAction = {
   type: 'DELETE_TOKEN';
 };
 
+type UpdateFreeTextAction = {
+  text: string;
+  token: TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES>;
+  type: 'UPDATE_FREE_TEXT';
+};
+
 type UpdateFilterOpAction = {
   op: TermOperator;
   token: TokenResult<Token.FILTER>;
@@ -48,13 +55,20 @@ type ClickTokenValueAction = {
   type: 'CLICK_TOKEN_VALUE';
 };
 
+type FocusFreeTextAction = {
+  cursor: number;
+  type: 'FOCUS_FREE_TEXT';
+};
+
 export type QueryBuilderActions =
   | DeleteTokenAction
+  | UpdateFreeTextAction
   | UpdateFilterOpAction
   | UpdateTokenValueAction
   | ExitTokenAction
   | ClickTokenOpAction
-  | ClickTokenValueAction;
+  | ClickTokenValueAction
+  | FocusFreeTextAction;
 
 function removeQueryToken(query: string, token: TokenResult<Token>): string {
   return (
@@ -85,11 +99,111 @@ function replaceQueryToken(
   token: TokenResult<Token>,
   value: string
 ): string {
-  return (
-    query.substring(0, token.location.start.offset) +
-    value +
-    query.substring(token.location.end.offset)
+  const start = query.substring(0, token.location.start.offset);
+  const end = query.substring(token.location.end.offset);
+
+  return start + value + end;
+}
+
+// Ensures that the replaced token is separated from the rest of the query
+// and cleans up any extra whitespace
+function replaceTokenWithPadding(
+  query: string,
+  token: TokenResult<Token>,
+  value: string
+): string {
+  const start = query.substring(0, token.location.start.offset);
+  const end = query.substring(token.location.end.offset);
+
+  return (start.trimEnd() + ' ' + value.trim() + ' ' + end.trimStart()).trim();
+}
+
+// Sets focus to the end of the query
+function createEndFocusState(query: string): QueryBuilderFocusState {
+  return {
+    type: QueryBuilderFocusType.TOKEN,
+    range: {
+      start: query.length,
+      end: query.length,
+    },
+  };
+}
+
+function resetFocus(state: QueryBuilderState): QueryBuilderState {
+  return {
+    ...state,
+    focus: createEndFocusState(state.query),
+  };
+}
+
+function findMatchingFilterToken({
+  query,
+  originalToken,
+  newFilterToken,
+}: {
+  newFilterToken: TokenResult<Token.FILTER>;
+  originalToken: TokenResult<Token>;
+  query: string;
+}): TokenResult<Token.FILTER> | null {
+  const parsedQuery = parseSearch(query);
+
+  for (const token of parsedQuery ?? []) {
+    if (
+      token.location.start.offset >= originalToken.location.start.offset &&
+      token.type === Token.FILTER &&
+      token.key.text === newFilterToken.key.text
+    ) {
+      return token;
+    }
+  }
+
+  return null;
+}
+
+function calculateNewFocusAfterFreeTextUpdate(
+  query: string,
+  action: UpdateFreeTextAction
+) {
+  const parsed = parseSearch(action.text);
+  const newFilterToken = parsed?.find(
+    (token): token is TokenResult<Token.FILTER> => token.type === Token.FILTER
   );
+
+  if (!newFilterToken) {
+    return createEndFocusState(query);
+  }
+
+  const matchingToken = findMatchingFilterToken({
+    query,
+    originalToken: action.token,
+    newFilterToken,
+  });
+
+  if (!matchingToken) {
+    return createEndFocusState(query);
+  }
+
+  return {
+    type: QueryBuilderFocusType.FILTER_VALUE,
+    range: {
+      start: matchingToken.location.start.offset,
+      end: matchingToken.location.end.offset,
+    },
+    editing: true,
+  };
+}
+
+function updateFreeText(
+  state: QueryBuilderState,
+  action: UpdateFreeTextAction
+): QueryBuilderState {
+  const newQuery = replaceTokenWithPadding(state.query, action.token, action.text);
+
+  return {
+    ...state,
+    focus: calculateNewFocusAfterFreeTextUpdate(newQuery, action),
+    query: newQuery,
+  };
 }
 
 export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
@@ -99,28 +213,26 @@ export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
     (state, action): QueryBuilderState => {
       switch (action.type) {
         case 'DELETE_TOKEN':
-          return {
+          return resetFocus({
             ...state,
             query: removeQueryToken(state.query, action.token),
-            focus: null,
-          };
+          });
+        case 'UPDATE_FREE_TEXT':
+          return updateFreeText(state, action);
         case 'UPDATE_FILTER_OP':
-          return {
+          return resetFocus({
             ...state,
             query: modifyFilterOperator(state.query, action.token, action.op),
-            focus: null,
-          };
+          });
         case 'UPDATE_TOKEN_VALUE':
-          return {
+          return resetFocus({
             ...state,
             query: replaceQueryToken(state.query, action.token, action.value),
-            focus: null,
-          };
+          });
         case 'EXIT_TOKEN':
-          return {
+          return resetFocus({
             ...state,
-            focus: null,
-          };
+          });
         case 'CLICK_TOKEN_OP':
           return {
             ...state,
@@ -144,7 +256,17 @@ export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
               editing: true,
             },
           };
-
+        case 'FOCUS_FREE_TEXT':
+          return {
+            ...state,
+            focus: {
+              type: QueryBuilderFocusType.TOKEN,
+              range: {
+                start: action.cursor,
+                end: action.cursor,
+              },
+            },
+          };
         default:
           return state;
       }

+ 42 - 2
static/app/components/searchQueryBuilder/utils.tsx

@@ -2,12 +2,52 @@ import type {QueryBuilderFocusState} from 'sentry/components/searchQueryBuilder/
 import {
   filterTypeConfig,
   interchangeableFilterOperators,
+  type ParseResult,
+  type ParseResultToken,
   type TermOperator,
   Token,
   type TokenResult,
 } from 'sentry/components/searchSyntax/parser';
 import {escapeDoubleQuotes} from 'sentry/utils';
 
+export function makeTokenKey(token: TokenResult<Token>) {
+  return `${token.type}:${token.location.start.offset}`;
+}
+
+const isSimpleTextToken = (
+  token: ParseResultToken
+): token is TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES> => {
+  return [Token.FREE_TEXT, Token.SPACES].includes(token.type);
+};
+
+/**
+ * Collapse adjacent FREE_TEXT and SPACES tokens into a single token.
+ * This is useful for rendering the minimum number of inputs in the UI.
+ */
+export function collapseTextTokens(tokens: ParseResult | null) {
+  if (!tokens) {
+    return null;
+  }
+
+  return tokens.reduce<ParseResult>((acc, token) => {
+    if (acc.length === 0) {
+      return [token];
+    }
+
+    const lastToken = acc[acc.length - 1];
+
+    if (isSimpleTextToken(token) && isSimpleTextToken(lastToken)) {
+      lastToken.value += token.value;
+      lastToken.text += token.value;
+      lastToken.location.end = token.location.end;
+      lastToken.type = Token.FREE_TEXT;
+      return acc;
+    }
+
+    return [...acc, token];
+  }, []);
+}
+
 export function getValidOpsForFilter(
   filterToken: TokenResult<Token.FILTER>
 ): readonly TermOperator[] {
@@ -39,8 +79,8 @@ export function focusIsWithinToken(
   }
 
   return (
-    focus.range.start >= token.location.start.offset &&
-    focus.range.end <= token.location.end.offset
+    token.location.start.offset <= focus.range.start &&
+    token.location.end.offset >= focus.range.end
   );
 }
 

+ 19 - 15
static/app/components/searchQueryBuilder/valueCombobox.tsx

@@ -1,4 +1,4 @@
-import {useMemo, useState} from 'react';
+import {useCallback, useMemo, useState} from 'react';
 import {Item, Section} from '@react-stately/collections';
 
 import {getItemsWithKeys} from 'sentry/components/compactSelect/utils';
@@ -32,14 +32,12 @@ function getPredefinedValues({key}: {key?: Tag}): string[] {
 
   const fieldDef = getFieldDefinition(key.key);
 
-  if (!key.values) {
-    return [];
-  }
-
-  if (isStringFilterValues(key.values)) {
+  if (key.values && isStringFilterValues(key.values)) {
     return key.values;
   }
 
+  // TODO(malwilley): Add support for grouped values
+
   switch (fieldDef?.valueType) {
     // TODO(malwilley): Better duration suggestions
     case FieldValueType.DURATION:
@@ -84,25 +82,31 @@ export function SearchQueryBuilderValueCombobox({token}: SearchQueryValueBuilder
     );
   }, [data, key, shouldFetchValues]);
 
+  const handleSelectValue = useCallback(
+    (value: string) => {
+      dispatch({
+        type: 'UPDATE_TOKEN_VALUE',
+        token: token.value,
+        value: escapeTagValue(value),
+      });
+    },
+    [dispatch, token.value]
+  );
+
   return (
     // TODO(malwilley): Support for multiple values
     <SearchQueryBuilderCombobox
       items={items}
-      onChange={value => {
-        dispatch({
-          type: 'UPDATE_TOKEN_VALUE',
-          token: token.value,
-          value: escapeTagValue(value),
-        });
-      }}
+      onOptionSelected={handleSelectValue}
+      onCustomValueSelected={handleSelectValue}
       onExit={() => {
         dispatch({type: 'EXIT_TOKEN'});
       }}
       inputValue={inputValue}
-      setInputValue={setInputValue}
       placeholder={formatFilterValue(token)}
       token={token}
-      inputLabel={t('Filter value')}
+      inputLabel={t('Edit filter value')}
+      onInputChange={e => setInputValue(e.target.value)}
     >
       <Section>
         {items.map(item => (