Browse Source

feat(smartSearch): Update autocomplete with each word (#36986)

When moving the cursor position, update the autocomplete to use the curently selected word
Also replace only the currently selected word
This fixes the issue where free text in front is ignored
Jenn Mueng 2 years ago
parent
commit
eb7be4c437

+ 57 - 12
static/app/components/smartSearchBar/index.tsx

@@ -283,6 +283,7 @@ type State = {
    * autocompletion for.
    */
   searchTerm: string;
+
   /**
    * Boolean indicating if dropdown should be shown
    */
@@ -852,6 +853,47 @@ class SmartSearchBar extends Component<Props, State> {
     return this.searchInput.current.selectionStart ?? -1;
   }
 
+  /**
+   * Get the search term at the current cursor position
+   */
+  get cursorSearchTerm() {
+    const cursorPosition = this.cursorPosition;
+    const cursorToken = this.cursorToken;
+
+    if (!cursorToken) {
+      return null;
+    }
+
+    const LIMITER_CHARS = [' ', ':'];
+
+    const innerStart = cursorPosition - cursorToken.location.start.offset;
+
+    let tokenStart = innerStart;
+    while (tokenStart > 0 && !LIMITER_CHARS.includes(cursorToken.text[tokenStart - 1])) {
+      tokenStart--;
+    }
+    let tokenEnd = innerStart;
+    while (
+      tokenEnd < cursorToken.text.length &&
+      !LIMITER_CHARS.includes(cursorToken.text[tokenEnd])
+    ) {
+      tokenEnd++;
+    }
+
+    let searchTerm = cursorToken.text.slice(tokenStart, tokenEnd);
+
+    if (searchTerm.startsWith(NEGATION_OPERATOR)) {
+      tokenStart++;
+    }
+    searchTerm = searchTerm.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
+
+    return {
+      end: cursorToken.location.start.offset + tokenEnd,
+      searchTerm,
+      start: cursorToken.location.start.offset + tokenStart,
+    };
+  }
+
   get filterTokens(): TokenResult<Token.Filter>[] {
     return (this.state.parsedQuery?.filter(tok => tok.type === Token.Filter) ??
       []) as TokenResult<Token.Filter>[];
@@ -1247,7 +1289,9 @@ class SmartSearchBar extends Component<Props, State> {
         }
 
         if (cursor === this.cursorPosition) {
-          this.setState({searchTerm: tagName});
+          this.setState({
+            searchTerm: tagName,
+          });
           this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
         }
         return;
@@ -1259,13 +1303,16 @@ class SmartSearchBar extends Component<Props, State> {
       return;
     }
 
-    if (cursorToken.type === Token.FreeText) {
-      const lastToken = cursorToken.text.trim().split(' ').pop() ?? '';
-      const keyText = lastToken.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
-      const autocompleteGroups = [await this.generateTagAutocompleteGroup(keyText)];
+    const cursorSearchTerm = this.cursorSearchTerm;
+    if (cursorToken.type === Token.FreeText && cursorSearchTerm) {
+      const autocompleteGroups = [
+        await this.generateTagAutocompleteGroup(cursorSearchTerm.searchTerm),
+      ];
 
       if (cursor === this.cursorPosition) {
-        this.setState({searchTerm: keyText});
+        this.setState({
+          searchTerm: cursorSearchTerm.searchTerm,
+        });
         this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
       }
       return;
@@ -1437,12 +1484,10 @@ class SmartSearchBar extends Component<Props, State> {
       }
     }
 
-    if (cursorToken.type === Token.FreeText) {
-      const startPos = cursorToken.location.start.offset;
-      clauseStart = cursorToken.text.startsWith(NEGATION_OPERATOR)
-        ? startPos + 1
-        : startPos;
-      clauseEnd = cursorToken.location.end.offset;
+    const cursorSearchTerm = this.cursorSearchTerm;
+    if (cursorToken.type === Token.FreeText && cursorSearchTerm) {
+      clauseStart = cursorSearchTerm.start;
+      clauseEnd = cursorSearchTerm.end;
     }
 
     if (clauseStart !== null && clauseEnd !== null) {

+ 163 - 1
tests/js/spec/components/smartSearchBar/index.spec.jsx

@@ -857,6 +857,133 @@ describe('SmartSearchBar', function () {
     });
   });
 
+  describe('cursorSearchTerm', function () {
+    it('selects the correct free text word', async function () {
+      jest.useRealTimers();
+
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 6);
+      textarea.simulate('change', {target: {value: 'typ testest    err'}});
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('testest');
+      expect(cursorSearchTerm.start).toBe(4);
+      expect(cursorSearchTerm.end).toBe(11);
+    });
+
+    it('selects the correct free text word (last word)', async function () {
+      jest.useRealTimers();
+
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 15);
+      textarea.simulate('change', {target: {value: 'typ testest    err'}});
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('err');
+      expect(cursorSearchTerm.start).toBe(15);
+      expect(cursorSearchTerm.end).toBe(18);
+    });
+
+    it('selects the correct free text word (first word)', async function () {
+      jest.useRealTimers();
+
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 1);
+      textarea.simulate('change', {target: {value: 'typ testest    err'}});
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('typ');
+      expect(cursorSearchTerm.start).toBe(0);
+      expect(cursorSearchTerm.end).toBe(3);
+    });
+
+    it('search term location correctly selects key of filter token', async function () {
+      jest.useRealTimers();
+
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 6);
+      textarea.simulate('change', {target: {value: 'typ device:123'}});
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('device');
+      expect(cursorSearchTerm.start).toBe(4);
+      expect(cursorSearchTerm.end).toBe(10);
+    });
+
+    it('search term location correctly selects value of filter token', async function () {
+      jest.useRealTimers();
+
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 11);
+      textarea.simulate('change', {target: {value: 'typ device:123'}});
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('123');
+      expect(cursorSearchTerm.start).toBe(11);
+      expect(cursorSearchTerm.end).toBe(14);
+    });
+  });
+
   describe('onAutoComplete()', function () {
     it('completes terms from the list', function () {
       const props = {
@@ -916,7 +1043,8 @@ describe('SmartSearchBar', function () {
       );
     });
 
-    it('keeps the negation operator is present', function () {
+    it('keeps the negation operator present', async function () {
+      jest.useRealTimers();
       const props = {
         query: '',
         organization,
@@ -927,8 +1055,15 @@ describe('SmartSearchBar', function () {
       const searchBar = smartSearchBar.instance();
       const textarea = smartSearchBar.find('textarea');
       // start typing part of the tag prefixed by the negation operator!
+      textarea.simulate('focus');
       textarea.simulate('change', {target: {value: 'event.type:error !ti'}});
       mockCursorPosition(searchBar, 20);
+      await tick();
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('ti');
+      expect(cursorSearchTerm.start).toBe(18);
+      expect(cursorSearchTerm.end).toBe(20);
       // use autocompletion to do the rest
       searchBar.onAutoComplete('title:', {});
       expect(searchBar.state.query).toEqual('event.type:error !title:');
@@ -1138,6 +1273,33 @@ describe('SmartSearchBar', function () {
         );
       }
     });
+
+    it('replaces the correct word', async function () {
+      const props = {
+        query: '',
+        organization,
+        location,
+        supportedTags,
+      };
+      const smartSearchBar = mountWithTheme(<SmartSearchBar {...props} />, options);
+      const searchBar = smartSearchBar.instance();
+      const textarea = smartSearchBar.find('textarea');
+
+      textarea.simulate('focus');
+      mockCursorPosition(searchBar, 4);
+      textarea.simulate('change', {target: {value: 'typ ti err'}});
+
+      await tick();
+
+      // Expect the correct search term to be selected
+      const cursorSearchTerm = searchBar.cursorSearchTerm;
+      expect(cursorSearchTerm.searchTerm).toEqual('ti');
+      expect(cursorSearchTerm.start).toBe(4);
+      expect(cursorSearchTerm.end).toBe(6);
+      // use autocompletion to do the rest
+      searchBar.onAutoComplete('title:', {});
+      expect(searchBar.state.query).toEqual('typ title: err');
+    });
   });
 
   describe('Invalid field state', () => {