Repository: getsentry/sentry
Pull request: feat(perf): Add wildcard search support to transaction search bar
![Screen Shot 2022-11-11 at 10 48 45 AM](https://user-images.githubusercontent.com/989898/201380840-e366a4f8-b43e-45d4-b8e2-edfdff947bd1.png)

Closes #41237 and [PERF-1775](https://getsentry.atlassian.net/browse/PERF-1775). Backend support for wildcard searches was added in https://github.com/getsentry/sentry/pull/41114 and https://github.com/getsentry/sentry/pull/41103. This is the frontend piece. I didn't have to make any big changes, just a few small fixes

## Changes

- rename/refactor a few functions to give them clearer names that explain what they do and match their arguments
- allow the `Enter` key to submit the current search query without having to select an item from the dropdown
- split up search handling and submit handling so the `:` splitting logic won't try to split queries. Queries don't have `:` in them, only dropdown values do


Problem statement:
Add wildcard support to transaction name search
### Problem Statement

The "Search Transactions" bar on the Performance landing page does not support wildcards, so I can't search for a group of related transactions.

### Solution Brainstorm

It should support wildcard searches like the other search bars in Sentry. e.g., Typing something like `*settings*` and submitting the query should filter the transaction table to all transactions that match the wildcard.

Hints from issue discussion:
Routing to @getsentry/visibility for [triage](https://develop.sentry.dev/processing-tickets/#3-triage). ⏲️

Commit message: Clarify function name

Patch to review:
diff --git a/static/app/components/performance/searchBar.spec.tsx b/static/app/components/performance/searchBar.spec.tsx
index 2947d9c30fda4c..bf35248e73d5d9 100644
--- a/static/app/components/performance/searchBar.spec.tsx
+++ b/static/app/components/performance/searchBar.spec.tsx
@@ -142,4 +142,33 @@ describe('SearchBar', () => {
     expect(onSearch).toHaveBeenCalledTimes(1);
     expect(onSearch).toHaveBeenCalledWith('transaction:clients.fetch');
   });
+
+  it('Submits wildcard searches', async () => {
+    const onSearch = jest.fn();
+    eventsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/eventsv2/`,
+      body: {
+        data: [
+          {project_id: 1, transaction: 'clients.call'},
+          {project_id: 1, transaction: 'clients.fetch'},
+        ],
+      },
+    });
+    render(<SearchBar {...testProps} onSearch={onSearch} />);
+
+    userEvent.paste(screen.getByRole('textbox'), 'client*');
+    expect(screen.getByTestId('smart-search-dropdown')).toBeInTheDocument();
+
+    act(() => {
+      jest.runAllTimers();
+    });
+
+    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+    userEvent.keyboard('{Enter}');
+
+    expect(screen.queryByTestId('smart-search-dropdown')).not.toBeInTheDocument();
+    expect(onSearch).toHaveBeenCalledTimes(1);
+    expect(onSearch).toHaveBeenCalledWith('transaction:client*');
+  });
 });
diff --git a/static/app/components/performance/searchBar.tsx b/static/app/components/performance/searchBar.tsx
index 98ce151a10133b..ba1e913df0e60a 100644
--- a/static/app/components/performance/searchBar.tsx
+++ b/static/app/components/performance/searchBar.tsx
@@ -49,12 +49,6 @@ function SearchBar(props: SearchBarProps) {
 
   const projectIdStrings = (eventView.project as Readonly<number>[])?.map(String);
 
-  const prepareQuery = (query: string) => {
-    const prependedChar = query[0] === '*' ? '' : '*';
-    const appendedChar = query[query.length - 1] === '*' ? '' : '*';
-    return `${prependedChar}${query}${appendedChar}`;
-  };
-
   const handleSearchChange = query => {
     setSearchString(query);
 
@@ -119,13 +113,13 @@ function SearchBar(props: SearchBarProps) {
 
     if (key === 'Enter') {
       event.preventDefault();
-      const currentItem = searchResults[0].children[highlightedItemIndex];
-      if (!currentItem?.value) {
-        return;
-      }
+      const currentItem = searchResults[0]?.children[highlightedItemIndex];
 
-      handleSearch(currentItem.value);
-      return;
+      if (currentItem?.value) {
+        handleChooseItem(currentItem.value);
+      } else {
+        handleSearch(searchString);
+      }
     }
   };
 
@@ -136,7 +130,7 @@ function SearchBar(props: SearchBarProps) {
         try {
           setLoading(true);
           const conditions = new MutableSearch('');
-          conditions.addFilterValues('transaction', [prepareQuery(query)], false);
+          conditions.addFilterValues('transaction', [wrapQueryInWildcards(query)], false);
           conditions.addFilterValues('event.type', ['transaction']);
 
           // clear any active requests
@@ -145,7 +139,7 @@ function SearchBar(props: SearchBarProps) {
           }
 
           const [results] = await doDiscoverQuery<{
-            data: Array<{'count()': number; project_id: number; transaction: string}>;
+            data: DataItem[];
           }>(api, url, {
             field: ['transaction', 'project_id', 'count()'],
             project: projectIdStrings,
@@ -158,7 +152,7 @@ function SearchBar(props: SearchBarProps) {
           const parsedResults = results.data.reduce(
             (searchGroup: SearchGroup, item) => {
               searchGroup.children.push({
-                value: `${item.transaction}:${item.project_id}`,
+                value: encodeItemToValue(item),
                 title: item.transaction,
                 type: ItemType.LINK,
                 desc: '',
@@ -188,28 +182,36 @@ function SearchBar(props: SearchBarProps) {
     [api, url, eventView.statsPeriod, projectIdStrings.join(',')]
   );
 
+  const handleChooseItem = (value: string) => {
+    const item = decodeValueToItem(value);
+    handleSearch(item.transaction);
+  };
+
+  const handleClickItemIcon = (value: string) => {
+    const item = decodeValueToItem(value);
+    navigateToItemTransactionSummary(item);
+  };
+
   const handleSearch = (query: string) => {
-    const lastIndex = query.lastIndexOf(':');
-    const transactionName = query.slice(0, lastIndex);
     setSearchResults([]);
-    setSearchString(transactionName);
-    onSearch(`transaction:${transactionName}`);
+    setSearchString(query);
+    onSearch(`transaction:${query}`);
     closeDropdown();
   };
 
-  const navigateToTransactionSummary = (name: string) => {
-    const lastIndex = name.lastIndexOf(':');
-    const transactionName = name.slice(0, lastIndex);
-    const projectId = name.slice(lastIndex + 1);
+  const navigateToItemTransactionSummary = (item: DataItem) => {
+    const {transaction, project_id} = item;
+
     const query = eventView.generateQueryStringObject();
     setSearchResults([]);
 
     const next = transactionSummaryRouteWithQuery({
       orgSlug: organization.slug,
-      transaction: String(transactionName),
-      projectID: projectId,
+      transaction,
+      projectID: String(project_id),
       query,
     });
+
     browserHistory.push(next);
   };
 
@@ -227,14 +229,39 @@ function SearchBar(props: SearchBarProps) {
           searchSubstring={searchString}
           loading={loading}
           items={searchResults}
-          onClick={handleSearch}
-          onIconClick={navigateToTransactionSummary}
+          onClick={handleChooseItem}
+          onIconClick={handleClickItemIcon}
         />
       )}
     </Container>
   );
 }
 
+const encodeItemToValue = (item: DataItem) => {
+  return `${item.transaction}:${item.project_id}`;
+};
+
+const decodeValueToItem = (value: string): DataItem => {
+  const lastIndex = value.lastIndexOf(':');
+
+  return {
+    project_id: parseInt(value.slice(lastIndex + 1), 10),
+    transaction: value.slice(0, lastIndex),
+  };
+};
+
+interface DataItem {
+  project_id: number;
+  transaction: string;
+  'count()'?: number;
+}
+
+const wrapQueryInWildcards = (query: string) => {
+  const prependedChar = query[0] === '*' ? '' : '*';
+  const appendedChar = query[query.length - 1] === '*' ? '' : '*';
+  return `${prependedChar}${query}${appendedChar}`;
+};
+
 const Container = styled('div')`
   position: relative;
 `;


Code review comments:
diff_hunk: @@ -227,14 +229,39 @@ function SearchBar(props: SearchBarProps) {
           searchSubstring={searchString}
           loading={loading}
           items={searchResults}
-          onClick={handleSearch}
-          onIconClick={navigateToTransactionSummary}
+          onClick={handleChooseItem}
+          onIconClick={handleClickItemIcon}
         />
       )}
     </Container>
   );
 }
 
+const encodeItemToValue = (item: DataItem) => {
+  return `${item.transaction}:${item.project_id}`;
+};
+
+const decodeValueToItem = (value: string): DataItem => {
+  const lastIndex = value.lastIndexOf(':');
+
+  return {
+    project_id: parseInt(value.slice(lastIndex + 1), 10),
+    transaction: value.slice(0, lastIndex),
+  };
+};
+
+interface DataItem {
+  project_id: number;
+  transaction: string;
+  'count()'?: number;
+}
+
+const wrapQueryInWildcards = (query: string) => {
+  const prependedChar = query[0] === '*' ? '' : '*';
+  const appendedChar = query[query.length - 1] === '*' ? '' : '*';
path: static/app/components/performance/searchBar.tsx
text: @user1:
Nit, I'd say its best to use built in functions for this if possible
```suggestion
  const prependedChar = query.startsWith('*') ? '' : '*';
  const appendedChar = query.endsWith('*') ? '' : '*';
```

@author:
😅 this is the code that was there, I only moved it. I didn't want to change every single thing! In any cause. In cany case, since you called it out, I changed the function a bit to make the intent crystal clear

@user1:
Sorry, that is definitely fair! No need to touch it if its not part of the PR, but I suppose why not! 

@author:
I feel pretty good about it since the unit tests are there 👍🏻