Просмотр исходного кода

feat(ui): AST-aware autocomplete (#26764)

Uses the AST provided in #26982 and traverses it to find the token that the user is on. Presents tag/operator/value autocomplete entries accordingly and replaces the appropriate text using the AST's location data.
David Wang 3 лет назад
Родитель
Сommit
195044d5fe

+ 18 - 25
static/app/components/searchSyntax/parser.tsx

@@ -9,6 +9,7 @@ import {
 } from 'app/utils/discover/fields';
 } from 'app/utils/discover/fields';
 
 
 import grammar from './grammar.pegjs';
 import grammar from './grammar.pegjs';
+import {getKeyName} from './utils';
 
 
 type TextFn = () => string;
 type TextFn = () => string;
 type LocationFn = () => LocationRange;
 type LocationFn = () => LocationRange;
@@ -104,6 +105,17 @@ const allOperators = [
   TermOperator.NotEqual,
   TermOperator.NotEqual,
 ] as const;
 ] as const;
 
 
+const basicOperators = [TermOperator.Default, TermOperator.NotEqual] as const;
+
+/**
+ * Map of certain filter types to other filter types with applicable operators
+ * e.g. SpecificDate can use the operators from Date to become a Date filter.
+ */
+export const interchangeableFilterOperators = {
+  [FilterType.SpecificDate]: [FilterType.Date],
+  [FilterType.Date]: [FilterType.SpecificDate],
+};
+
 const textKeys = [Token.KeySimple, Token.KeyExplicitTag] as const;
 const textKeys = [Token.KeySimple, Token.KeyExplicitTag] as const;
 
 
 const numberUnits = {
 const numberUnits = {
@@ -123,7 +135,7 @@ const numberUnits = {
 export const filterTypeConfig = {
 export const filterTypeConfig = {
   [FilterType.Text]: {
   [FilterType.Text]: {
     validKeys: textKeys,
     validKeys: textKeys,
-    validOps: [],
+    validOps: basicOperators,
     validValues: [Token.ValueText],
     validValues: [Token.ValueText],
     canNegate: true,
     canNegate: true,
   },
   },
@@ -171,7 +183,7 @@ export const filterTypeConfig = {
   },
   },
   [FilterType.Boolean]: {
   [FilterType.Boolean]: {
     validKeys: [Token.KeySimple],
     validKeys: [Token.KeySimple],
-    validOps: [],
+    validOps: basicOperators,
     validValues: [Token.ValueBoolean],
     validValues: [Token.ValueBoolean],
     canNegate: true,
     canNegate: true,
   },
   },
@@ -207,13 +219,13 @@ export const filterTypeConfig = {
   },
   },
   [FilterType.Has]: {
   [FilterType.Has]: {
     validKeys: [Token.KeySimple],
     validKeys: [Token.KeySimple],
-    validOps: [],
+    validOps: basicOperators,
     validValues: [],
     validValues: [],
     canNegate: true,
     canNegate: true,
   },
   },
   [FilterType.Is]: {
   [FilterType.Is]: {
     validKeys: [Token.KeySimple],
     validKeys: [Token.KeySimple],
-    validOps: [],
+    validOps: basicOperators,
     validValues: [Token.ValueText],
     validValues: [Token.ValueText],
     canNegate: true,
     canNegate: true,
   },
   },
@@ -281,26 +293,6 @@ type TextFilter = FilterMap[FilterType.Text];
  */
  */
 type FilterResult = FilterMap[FilterType];
 type FilterResult = FilterMap[FilterType];
 
 
-/**
- * Utility to get the string name of any type of key.
- */
-const getKeyName = (
-  key: ReturnType<
-    TokenConverter['tokenKeySimple' | 'tokenKeyExplicitTag' | 'tokenKeyAggregate']
-  >
-) => {
-  switch (key.type) {
-    case Token.KeySimple:
-      return key.value;
-    case Token.KeyExplicitTag:
-      return key.key.value;
-    case Token.KeyAggregate:
-      return key.name.value;
-    default:
-      return '';
-  }
-};
-
 type TokenConverterOpts = {
 type TokenConverterOpts = {
   text: TextFn;
   text: TextFn;
   location: LocationFn;
   location: LocationFn;
@@ -310,7 +302,7 @@ type TokenConverterOpts = {
 /**
 /**
  * Used to construct token results via the token grammar
  * Used to construct token results via the token grammar
  */
  */
-class TokenConverter {
+export class TokenConverter {
   text: TextFn;
   text: TextFn;
   location: LocationFn;
   location: LocationFn;
   config: SearchConfig;
   config: SearchConfig;
@@ -741,6 +733,7 @@ const defaultConfig: SearchConfig = {
     'first_seen',
     'first_seen',
     'last_seen',
     'last_seen',
     'time',
     'time',
+    'event.timestamp',
     'timestamp',
     'timestamp',
     'timestamp.to_hour',
     'timestamp.to_hour',
     'timestamp.to_day',
     'timestamp.to_day',

+ 38 - 1
static/app/components/searchSyntax/utils.tsx

@@ -1,4 +1,6 @@
-import {Token, TokenResult} from './parser';
+import {LocationRange} from 'pegjs';
+
+import {Token, TokenConverter, TokenResult} from './parser';
 
 
 /**
 /**
  * Utility function to visit every Token node within an AST tree and apply
  * Utility function to visit every Token node within an AST tree and apply
@@ -54,3 +56,38 @@ export function treeTransformer(
 
 
   return tree.map(nodeVisitor);
   return tree.map(nodeVisitor);
 }
 }
+
+type GetKeyNameOpts = {
+  /**
+   * Include arguments in aggregate key names
+   */
+  aggregateWithArgs?: boolean;
+};
+
+/**
+ * Utility to get the string name of any type of key.
+ */
+export const getKeyName = (
+  key: ReturnType<
+    TokenConverter['tokenKeySimple' | 'tokenKeyExplicitTag' | 'tokenKeyAggregate']
+  >,
+  options: GetKeyNameOpts = {}
+) => {
+  const {aggregateWithArgs} = options;
+  switch (key.type) {
+    case Token.KeySimple:
+      return key.value;
+    case Token.KeyExplicitTag:
+      return key.key.value;
+    case Token.KeyAggregate:
+      return aggregateWithArgs
+        ? `${key.name.value}(${key.args ? key.args.text : ''})`
+        : key.name.value;
+    default:
+      return '';
+  }
+};
+
+export function isWithinToken(node: {location: LocationRange}, position: number) {
+  return position >= node.location.start.offset && position <= node.location.end.offset;
+}

+ 334 - 72
static/app/components/smartSearchBar/index.tsx

@@ -12,8 +12,14 @@ import {Client} from 'app/api';
 import ButtonBar from 'app/components/buttonBar';
 import ButtonBar from 'app/components/buttonBar';
 import DropdownLink from 'app/components/dropdownLink';
 import DropdownLink from 'app/components/dropdownLink';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
-import {ParseResult, parseSearch} from 'app/components/searchSyntax/parser';
+import {
+  ParseResult,
+  parseSearch,
+  TermOperator,
+  Token,
+} from 'app/components/searchSyntax/parser';
 import HighlightQuery from 'app/components/searchSyntax/renderer';
 import HighlightQuery from 'app/components/searchSyntax/renderer';
+import {getKeyName, isWithinToken} from 'app/components/searchSyntax/utils';
 import {
 import {
   DEFAULT_DEBOUNCE_DURATION,
   DEFAULT_DEBOUNCE_DURATION,
   MAX_AUTOCOMPLETE_RELEASES,
   MAX_AUTOCOMPLETE_RELEASES,
@@ -37,8 +43,10 @@ import {
   addSpace,
   addSpace,
   createSearchGroups,
   createSearchGroups,
   filterSearchGroupsByIndex,
   filterSearchGroupsByIndex,
+  generateOperatorEntryMap,
   getLastTermIndex,
   getLastTermIndex,
   getQueryTerms,
   getQueryTerms,
+  getValidOps,
   removeSpace,
   removeSpace,
 } from './utils';
 } from './utils';
 
 
@@ -65,6 +73,20 @@ const makeQueryState = (query: string) => ({
   parsedQuery: parseSearch(query),
   parsedQuery: parseSearch(query),
 });
 });
 
 
+const generateOpAutocompleteGroup = (
+  validOps: readonly TermOperator[],
+  tagName: string
+): AutocompleteGroup => {
+  const operatorMap = generateOperatorEntryMap(tagName);
+  const operatorItems = validOps.map(op => operatorMap[op]);
+  return {
+    searchItems: operatorItems,
+    recentSearchItems: undefined,
+    tagName: '',
+    type: 'tag-operator' as ItemType,
+  };
+};
+
 type ActionProps = {
 type ActionProps = {
   api: Client;
   api: Client;
   /**
   /**
@@ -96,6 +118,13 @@ type ActionBarItem = {
   Action: React.ComponentType<ActionProps>;
   Action: React.ComponentType<ActionProps>;
 };
 };
 
 
+type AutocompleteGroup = {
+  searchItems: SearchItem[];
+  recentSearchItems: SearchItem[] | undefined;
+  tagName: string;
+  type: ItemType;
+};
+
 type Props = WithRouterProps & {
 type Props = WithRouterProps & {
   api: Client;
   api: Client;
   organization: LightWeightOrganization;
   organization: LightWeightOrganization;
@@ -513,6 +542,10 @@ class SmartSearchBar extends React.Component<Props, State> {
   };
   };
 
 
   onKeyUp = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
   onKeyUp = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (evt.key === 'ArrowLeft' || evt.key === 'ArrowRight') {
+      this.updateAutoCompleteItems();
+    }
+
     // Other keys are managed at onKeyDown function
     // Other keys are managed at onKeyDown function
     if (evt.key !== 'Escape') {
     if (evt.key !== 'Escape') {
       return;
       return;
@@ -749,6 +782,179 @@ class SmartSearchBar extends React.Component<Props, State> {
     return [];
     return [];
   };
   };
 
 
+  async generateTagAutocompleteGroup(tagName: string): Promise<AutocompleteGroup> {
+    const tagKeys = this.getTagKeys(tagName);
+    const recentSearches = await this.getRecentSearches();
+
+    return {
+      searchItems: tagKeys,
+      recentSearchItems: recentSearches ?? [],
+      tagName,
+      type: 'tag-key' as ItemType,
+    };
+  }
+
+  generateValueAutocompleteGroup = async (
+    tagName: string,
+    query: string
+  ): Promise<AutocompleteGroup | null> => {
+    const {prepareQuery, excludeEnvironment} = this.props;
+    const supportedTags = this.props.supportedTags ?? {};
+
+    const preparedQuery =
+      typeof prepareQuery === 'function' ? prepareQuery(query) : query;
+
+    // filter existing items immediately, until API can return
+    // with actual tag value results
+    const filteredSearchGroups = !preparedQuery
+      ? this.state.searchGroups
+      : this.state.searchGroups.filter(
+          item => item.value && item.value.indexOf(preparedQuery) !== -1
+        );
+
+    this.setState({
+      searchTerm: query,
+      searchGroups: filteredSearchGroups,
+    });
+
+    const tag = supportedTags[tagName];
+
+    if (!tag) {
+      return {
+        searchItems: [],
+        recentSearchItems: [],
+        tagName,
+        type: 'invalid-tag',
+      };
+    }
+
+    // Ignore the environment tag if the feature is active and
+    // excludeEnvironment = true
+    if (excludeEnvironment && tagName === 'environment') {
+      return null;
+    }
+
+    const fetchTagValuesFn =
+      tag.key === 'firstRelease'
+        ? this.getReleases
+        : tag.predefined
+        ? this.getPredefinedTagValues
+        : this.getTagValues;
+
+    const [tagValues, recentSearches] = await Promise.all([
+      fetchTagValuesFn(tag, preparedQuery),
+      this.getRecentSearches(),
+    ]);
+
+    return {
+      searchItems: tagValues ?? [],
+      recentSearchItems: recentSearches ?? [],
+      tagName: tag.key,
+      type: 'tag-value',
+    };
+  };
+
+  showDefaultSearches = async () => {
+    const {query} = this.state;
+    const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!;
+
+    if (!defaultSearchItems.length) {
+      // Update searchTerm, otherwise <SearchDropdown> will have wrong state
+      // (e.g. if you delete a query, the last letter will be highlighted if `searchTerm`
+      // does not get updated)
+      this.setState({searchTerm: query});
+
+      const tagKeys = this.getTagKeys('');
+      const recentSearches = await this.getRecentSearches();
+
+      this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', 'tag-key');
+      return;
+    }
+    // cursor on whitespace show default "help" search terms
+    this.setState({searchTerm: ''});
+
+    this.updateAutoCompleteState(defaultSearchItems, defaultRecentItems, '', 'default');
+    return;
+  };
+
+  getCursorToken = (
+    parsedQuery: ParseResult,
+    cursor: number
+  ): ParseResult[number] | null => {
+    for (const node of parsedQuery) {
+      if (node.type === Token.Spaces || !isWithinToken(node, cursor)) {
+        continue;
+      }
+
+      // traverse into a logic group to find specific filter
+      if (node.type === Token.LogicGroup) {
+        return this.getCursorToken(node.inner, cursor);
+      }
+
+      return node;
+    }
+    return null;
+  };
+
+  updateAutoCompleteFromAst = async () => {
+    const cursor = this.getCursorPosition();
+    const {parsedQuery} = this.state;
+
+    if (!parsedQuery) {
+      return;
+    }
+
+    const cursorToken = this.getCursorToken(parsedQuery, cursor);
+    if (!cursorToken) {
+      this.showDefaultSearches();
+      return;
+    }
+    if (cursorToken.type === Token.Filter) {
+      const tagName = getKeyName(cursorToken.key, {aggregateWithArgs: true});
+      // check if we are on the tag, value, or operator
+      if (isWithinToken(cursorToken.value, cursor)) {
+        const node = cursorToken.value;
+
+        const valueGroup = await this.generateValueAutocompleteGroup(tagName, node.text);
+        const autocompleteGroups = valueGroup ? [valueGroup] : [];
+        // show operator group if at beginning of value
+        if (cursor === node.location.start.offset) {
+          const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
+          autocompleteGroups.unshift(opGroup);
+        }
+        this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
+        return;
+      }
+
+      if (isWithinToken(cursorToken.key, cursor)) {
+        const node = cursorToken.key;
+        const autocompleteGroups = [await this.generateTagAutocompleteGroup(tagName)];
+        // show operator group if at end of key
+        if (cursor === node.location.end.offset) {
+          const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
+          autocompleteGroups.unshift(opGroup);
+        }
+        this.setState({searchTerm: tagName});
+        this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
+        return;
+      }
+
+      // show operator autocomplete group
+      const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
+      this.updateAutoCompleteStateMultiHeader([opGroup]);
+      return;
+    }
+
+    if (cursorToken.type === Token.FreeText) {
+      const autocompleteGroups = [
+        await this.generateTagAutocompleteGroup(cursorToken.text),
+      ];
+      this.setState({searchTerm: cursorToken.text});
+      this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
+      return;
+    }
+  };
+
   updateAutoCompleteItems = async () => {
   updateAutoCompleteItems = async () => {
     if (this.blurTimeout) {
     if (this.blurTimeout) {
       clearTimeout(this.blurTimeout);
       clearTimeout(this.blurTimeout);
@@ -756,7 +962,13 @@ class SmartSearchBar extends React.Component<Props, State> {
     }
     }
 
 
     const cursor = this.getCursorPosition();
     const cursor = this.getCursorPosition();
-    let query = this.state.query;
+    const {organization} = this.props;
+    if (organization.features.includes('search-syntax-highlight')) {
+      this.updateAutoCompleteFromAst();
+      return;
+    }
+
+    let {query} = this.state;
 
 
     // Don't continue if the query hasn't changed
     // Don't continue if the query hasn't changed
     if (query === this.state.previousQuery) {
     if (query === this.state.previousQuery) {
@@ -774,25 +986,7 @@ class SmartSearchBar extends React.Component<Props, State> {
       (terms.length === 1 && terms[0] === this.props.defaultQuery) || // default term
       (terms.length === 1 && terms[0] === this.props.defaultQuery) || // default term
       /^\s+$/.test(query.slice(cursor - 1, cursor + 1))
       /^\s+$/.test(query.slice(cursor - 1, cursor + 1))
     ) {
     ) {
-      const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!;
-
-      if (!defaultSearchItems.length) {
-        // Update searchTerm, otherwise <SearchDropdown> will have wrong state
-        // (e.g. if you delete a query, the last letter will be highlighted if `searchTerm`
-        // does not get updated)
-        this.setState({searchTerm: query});
-
-        const tagKeys = this.getTagKeys('');
-        const recentSearches = await this.getRecentSearches();
-
-        this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', 'tag-key');
-        return;
-      }
-
-      // cursor on whitespace show default "help" search terms
-      this.setState({searchTerm: ''});
-
-      this.updateAutoCompleteState(defaultSearchItems, defaultRecentItems, '', 'default');
+      this.showDefaultSearches();
       return;
       return;
     }
     }
 
 
@@ -820,9 +1014,6 @@ class SmartSearchBar extends React.Component<Props, State> {
       return;
       return;
     }
     }
 
 
-    const {prepareQuery, excludeEnvironment} = this.props;
-    const supportedTags = this.props.supportedTags ?? {};
-
     // TODO(billy): Better parsing for these examples
     // TODO(billy): Better parsing for these examples
     //
     //
     // > sentry:release:
     // > sentry:release:
@@ -832,54 +1023,16 @@ class SmartSearchBar extends React.Component<Props, State> {
     // e.g. given "!gpu" we want "gpu"
     // e.g. given "!gpu" we want "gpu"
     tagName = tagName.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
     tagName = tagName.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
     query = last.slice(index + 1);
     query = last.slice(index + 1);
-    const preparedQuery =
-      typeof prepareQuery === 'function' ? prepareQuery(query) : query;
-
-    // filter existing items immediately, until API can return
-    // with actual tag value results
-    const filteredSearchGroups = !preparedQuery
-      ? this.state.searchGroups
-      : this.state.searchGroups.filter(
-          item => item.value && item.value.indexOf(preparedQuery) !== -1
-        );
-
-    this.setState({
-      searchTerm: query,
-      searchGroups: filteredSearchGroups,
-    });
-
-    const tag = supportedTags[tagName];
-
-    if (!tag) {
-      this.updateAutoCompleteState([], [], tagName, 'invalid-tag');
-      return;
-    }
-
-    // Ignore the environment tag if the feature is active and
-    // excludeEnvironment = true
-    if (excludeEnvironment && tagName === 'environment') {
+    const valueGroup = await this.generateValueAutocompleteGroup(tagName, query);
+    if (valueGroup) {
+      this.updateAutoCompleteState(
+        valueGroup.searchItems ?? [],
+        valueGroup.recentSearchItems ?? [],
+        valueGroup.tagName,
+        valueGroup.type
+      );
       return;
       return;
     }
     }
-
-    const fetchTagValuesFn =
-      tag.key === 'firstRelease'
-        ? this.getReleases
-        : tag.predefined
-        ? this.getPredefinedTagValues
-        : this.getTagValues;
-
-    const [tagValues, recentSearches] = await Promise.all([
-      fetchTagValuesFn(tag, preparedQuery),
-      this.getRecentSearches(),
-    ]);
-
-    this.updateAutoCompleteState(
-      tagValues ?? [],
-      recentSearches ?? [],
-      tag.key,
-      'tag-value'
-    );
-    return;
   };
   };
 
 
   /**
   /**
@@ -897,7 +1050,7 @@ class SmartSearchBar extends React.Component<Props, State> {
     type: ItemType
     type: ItemType
   ) {
   ) {
     const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
     const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
-    const query = this.state.query;
+    const {query} = this.state;
 
 
     const queryCharsLeft =
     const queryCharsLeft =
       maxQueryLength && query ? maxQueryLength - query.length : undefined;
       maxQueryLength && query ? maxQueryLength - query.length : undefined;
@@ -914,6 +1067,109 @@ class SmartSearchBar extends React.Component<Props, State> {
     );
     );
   }
   }
 
 
+  /**
+   * Updates autocomplete dropdown items and autocomplete index state
+   *
+   * @param groups Groups that will be used to populate the autocomplete dropdown
+   */
+  updateAutoCompleteStateMultiHeader = (groups: AutocompleteGroup[]) => {
+    const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
+    const {query} = this.state;
+    const queryCharsLeft =
+      maxQueryLength && query ? maxQueryLength - query.length : undefined;
+
+    const searchGroups = groups
+      .map(({searchItems, recentSearchItems, tagName, type}) =>
+        createSearchGroups(
+          searchItems,
+          hasRecentSearches ? recentSearchItems : undefined,
+          tagName,
+          type,
+          maxSearchItems,
+          queryCharsLeft
+        )
+      )
+      .reduce(
+        (acc, item) => ({
+          searchGroups: [...acc.searchGroups, ...item.searchGroups],
+          flatSearchItems: [...acc.flatSearchItems, ...item.flatSearchItems],
+          activeSearchItem: -1,
+        }),
+        {
+          searchGroups: [] as SearchGroup[],
+          flatSearchItems: [] as SearchItem[],
+          activeSearchItem: -1,
+        }
+      );
+
+    this.setState(searchGroups);
+  };
+
+  onAutoCompleteFromAst = (replaceText: string, item: SearchItem) => {
+    const cursor = this.getCursorPosition();
+    const {parsedQuery, query} = this.state;
+    if (!parsedQuery) {
+      return;
+    }
+    const cursorToken = this.getCursorToken(parsedQuery, cursor);
+    if (!cursorToken) {
+      return;
+    }
+
+    // the start and end of what to replace
+    let clauseStart: null | number = null;
+    let clauseEnd: null | number = null;
+    // the new text that will exist between clauseStart and clauseEnd
+    let replaceToken = replaceText;
+    if (cursorToken.type === Token.Filter) {
+      if (item.type === 'tag-operator') {
+        const valueLocation = cursorToken.value.location;
+        clauseStart = cursorToken.location.start.offset;
+        clauseEnd = valueLocation.start.offset;
+        if (replaceText === '!:') {
+          replaceToken = `!${cursorToken.key.text}:`;
+        } else {
+          replaceToken = `${cursorToken.key.text}${replaceText}`;
+        }
+      } else if (isWithinToken(cursorToken.value, cursor)) {
+        const location = cursorToken.value.location;
+        const keyLocation = cursorToken.key.location;
+        // Include everything after the ':'
+        clauseStart = keyLocation.end.offset + 1;
+        clauseEnd = location.end.offset;
+      } else if (isWithinToken(cursorToken.key, cursor)) {
+        const location = cursorToken.key.location;
+        clauseStart = location.start.offset;
+        // If the token is a key, then trim off the end to avoid duplicate ':'
+        clauseEnd = location.end.offset + 1;
+      }
+    }
+
+    if (cursorToken.type === Token.FreeText) {
+      clauseStart = cursorToken.location.start.offset;
+      clauseEnd = cursorToken.location.end.offset;
+    }
+
+    if (clauseStart !== null && clauseEnd !== null) {
+      const beforeClause = query.substring(0, clauseStart);
+      const endClause = query.substring(clauseEnd);
+      const newQuery = `${beforeClause}${replaceToken}${endClause}`;
+      this.setState(makeQueryState(newQuery), () => {
+        // setting a new input value will lose focus; restore it
+        if (this.searchInput.current) {
+          this.searchInput.current.focus();
+          // set cursor to be end of the autocomplete clause
+          const newCursorPosition = beforeClause.length + replaceToken.length;
+          this.searchInput.current.selectionStart = newCursorPosition;
+          this.searchInput.current.selectionEnd = newCursorPosition;
+        }
+        // then update the autocomplete box with new items
+        this.updateAutoCompleteItems();
+        this.props.onChange?.(newQuery, new MouseEvent('click') as any);
+      });
+    }
+  };
+
   onAutoComplete = (replaceText: string, item: SearchItem) => {
   onAutoComplete = (replaceText: string, item: SearchItem) => {
     if (item.type === 'recent-search') {
     if (item.type === 'recent-search') {
       trackAnalyticsEvent({
       trackAnalyticsEvent({
@@ -934,7 +1190,13 @@ class SmartSearchBar extends React.Component<Props, State> {
     }
     }
 
 
     const cursor = this.getCursorPosition();
     const cursor = this.getCursorPosition();
-    const query = this.state.query;
+    const {query} = this.state;
+
+    const {organization} = this.props;
+    if (organization.features.includes('search-syntax-highlight')) {
+      this.onAutoCompleteFromAst(replaceText, item);
+      return;
+    }
 
 
     const lastTermIndex = getLastTermIndex(query, cursor);
     const lastTermIndex = getLastTermIndex(query, cursor);
     const terms = getQueryTerms(query, lastTermIndex);
     const terms = getQueryTerms(query, lastTermIndex);

+ 1 - 0
static/app/components/smartSearchBar/types.tsx

@@ -2,6 +2,7 @@ export type ItemType =
   | 'default'
   | 'default'
   | 'tag-key'
   | 'tag-key'
   | 'tag-value'
   | 'tag-value'
+  | 'tag-operator'
   | 'first-release'
   | 'first-release'
   | 'invalid-tag'
   | 'invalid-tag'
   | 'recent-search';
   | 'recent-search';

+ 73 - 0
static/app/components/smartSearchBar/utils.tsx

@@ -1,3 +1,10 @@
+import {
+  filterTypeConfig,
+  interchangeableFilterOperators,
+  TermOperator,
+  Token,
+  TokenResult,
+} from 'app/components/searchSyntax/parser';
 import {IconClock, IconStar, IconTag, IconToggle, IconUser} from 'app/icons';
 import {IconClock, IconStar, IconTag, IconToggle, IconUser} from 'app/icons';
 import {t} from 'app/locale';
 import {t} from 'app/locale';
 
 
@@ -51,6 +58,10 @@ function getTitleForType(type: ItemType) {
     return t('Common Search Terms');
     return t('Common Search Terms');
   }
   }
 
 
+  if (type === 'tag-operator') {
+    return t('Operator Helpers');
+  }
+
   return t('Tags');
   return t('Tags');
 }
 }
 
 
@@ -160,3 +171,65 @@ export function filterSearchGroupsByIndex(items: SearchGroup[], index: number) {
 
 
   return foundSearchItem;
   return foundSearchItem;
 }
 }
+
+export function generateOperatorEntryMap(tag: string) {
+  return {
+    [TermOperator.Default]: {
+      type: 'tag-operator' as ItemType,
+      value: ':',
+      desc: `${tag}:${t('[value] is equal to')}`,
+    },
+    [TermOperator.GreaterThanEqual]: {
+      type: 'tag-operator' as ItemType,
+      value: ':>=',
+      desc: `${tag}:${t('>=[value] is greater than or equal to')}`,
+    },
+    [TermOperator.LessThanEqual]: {
+      type: 'tag-operator' as ItemType,
+      value: ':<=',
+      desc: `${tag}:${t('<=[value] is less than or equal to')}`,
+    },
+    [TermOperator.GreaterThan]: {
+      type: 'tag-operator' as ItemType,
+      value: ':>',
+      desc: `${tag}:${t('>[value] is greater than')}`,
+    },
+    [TermOperator.LessThan]: {
+      type: 'tag-operator' as ItemType,
+      value: ':<',
+      desc: `${tag}:${t('<[value] is less than')}`,
+    },
+    [TermOperator.Equal]: {
+      type: 'tag-operator' as ItemType,
+      value: ':=',
+      desc: `${tag}:${t('=[value] is equal to')}`,
+    },
+    [TermOperator.NotEqual]: {
+      type: 'tag-operator' as ItemType,
+      value: '!:',
+      desc: `!${tag}:${t('[value] is not equal to')}`,
+    },
+  };
+}
+
+export function getValidOps(
+  filterToken: TokenResult<Token.Filter>
+): readonly TermOperator[] {
+  // If the token is invalid we want to use the possible expected types as our filter type
+  const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
+
+  // Determine any interchangable filter types for our valid types
+  const interchangeableTypes = validTypes.map(
+    type => interchangeableFilterOperators[type] ?? []
+  );
+
+  // Combine all types
+  const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
+
+  // Find all valid operations
+  const validOps = new Set<TermOperator>(
+    allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
+  );
+
+  return [...validOps];
+}

+ 1 - 0
tests/js/spec/views/eventsV2/results.spec.jsx

@@ -237,6 +237,7 @@ describe('EventsV2 > Results', function () {
     search.simulate('change', {target: {value: 'geo:canada'}}).simulate('submit', {
     search.simulate('change', {target: {value: 'geo:canada'}}).simulate('submit', {
       preventDefault() {},
       preventDefault() {},
     });
     });
+    await tick();
 
 
     // cursor query string should be omitted from the query string
     // cursor query string should be omitted from the query string
     expect(initialData.router.push).toHaveBeenCalledWith({
     expect(initialData.router.push).toHaveBeenCalledWith({