Browse Source

feat(search): Move quick actions into a footer (#35529)

Co-authored-by: NisanthanNanthakumar <nisanthan.nanthakumar@sentry.io>
Jenn Mueng 2 years ago
parent
commit
d7507b7111

+ 16 - 23
static/app/components/events/searchBar.tsx

@@ -1,5 +1,4 @@
 import {useEffect} from 'react';
-import {ClassNames} from '@emotion/react';
 import assign from 'lodash/assign';
 import flatten from 'lodash/flatten';
 import memoize from 'lodash/memoize';
@@ -60,6 +59,7 @@ function SearchBar(props: SearchBarProps) {
   useEffect(() => {
     // Clear memoized data on mount to make tests more consistent.
     getEventFieldValues.cache.clear?.();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [projectIds]);
 
   // Returns array of tag values that substring match `query`; invokes `callback`
@@ -132,28 +132,21 @@ function SearchBar(props: SearchBarProps) {
   return (
     <Measurements>
       {({measurements}) => (
-        <ClassNames>
-          {({css}) => (
-            <SmartSearchBar
-              hasRecentSearches
-              savedSearchType={SavedSearchType.EVENT}
-              onGetTagValues={getEventFieldValues}
-              supportedTags={getTagList(measurements)}
-              prepareQuery={query => {
-                // Prepare query string (e.g. strip special characters like negation operator)
-                return query.replace(SEARCH_SPECIAL_CHARS_REGEXP, '');
-              }}
-              maxSearchItems={maxSearchItems}
-              excludeEnvironment
-              dropdownClassName={css`
-                max-height: ${maxMenuHeight ?? 300}px;
-                overflow-y: auto;
-              `}
-              getFieldDoc={getFieldDoc}
-              {...props}
-            />
-          )}
-        </ClassNames>
+        <SmartSearchBar
+          hasRecentSearches
+          savedSearchType={SavedSearchType.EVENT}
+          onGetTagValues={getEventFieldValues}
+          supportedTags={getTagList(measurements)}
+          prepareQuery={query => {
+            // Prepare query string (e.g. strip special characters like negation operator)
+            return query.replace(SEARCH_SPECIAL_CHARS_REGEXP, '');
+          }}
+          maxSearchItems={maxSearchItems}
+          excludeEnvironment
+          maxMenuHeight={maxMenuHeight ?? 300}
+          getFieldDoc={getFieldDoc}
+          {...props}
+        />
       )}
     </Measurements>
   );

+ 1 - 1
static/app/components/hotkeysLabel.tsx

@@ -95,6 +95,6 @@ const HotkeysContainer = styled('div')`
   align-items: center;
 
   > * {
-    margin-right: ${space(1)};
+    margin-right: ${space(0.5)};
   }
 `;

+ 14 - 28
static/app/components/smartSearchBar/index.tsx

@@ -52,8 +52,8 @@ import {
   createSearchGroups,
   filterSearchGroupsByIndex,
   generateOperatorEntryMap,
-  getQuickActionsSearchGroup,
   getValidOps,
+  quickActions,
   removeSpace,
 } from './utils';
 
@@ -172,6 +172,10 @@ type Props = WithRouterProps & {
    * Allows additional content to be played before the search bar and icon
    */
   inlineLabel?: React.ReactNode;
+  /**
+   * Maximum height for the search dropdown menu
+   */
+  maxMenuHeight?: number;
   /**
    * Used to enforce length on the query
    */
@@ -1279,19 +1283,6 @@ class SmartSearchBar extends Component<Props, State> {
       queryCharsLeft
     );
 
-    const quickActionsSearchResults = getQuickActionsSearchGroup(
-      this.runQuickAction,
-      this.filterTokens.length,
-      this.cursorToken ?? undefined
-    );
-    if (quickActionsSearchResults) {
-      searchGroups.searchGroups.push(quickActionsSearchResults.searchGroup);
-      searchGroups.flatSearchItems = [
-        ...searchGroups.flatSearchItems,
-        ...quickActionsSearchResults.searchItems,
-      ];
-    }
-
     this.setState(searchGroups);
   }
 
@@ -1306,12 +1297,6 @@ class SmartSearchBar extends Component<Props, State> {
     const queryCharsLeft =
       maxQueryLength && query ? maxQueryLength - query.length : undefined;
 
-    const quickActionsSearchResults = getQuickActionsSearchGroup(
-      this.runQuickAction,
-      this.filterTokens.length,
-      this.cursorToken ?? undefined
-    );
-
     const searchGroups = groups
       .map(({searchItems, recentSearchItems, tagName, type}) =>
         createSearchGroups(
@@ -1336,14 +1321,6 @@ class SmartSearchBar extends Component<Props, State> {
         }
       );
 
-    if (quickActionsSearchResults) {
-      searchGroups.searchGroups.push(quickActionsSearchResults.searchGroup);
-      searchGroups.flatSearchItems = [
-        ...searchGroups.flatSearchItems,
-        ...quickActionsSearchResults.searchItems,
-      ];
-    }
-
     this.setState(searchGroups);
   };
 
@@ -1482,6 +1459,7 @@ class SmartSearchBar extends Component<Props, State> {
       useFormWrapper,
       inlineLabel,
       maxQueryLength,
+      maxMenuHeight,
     } = this.props;
 
     const {
@@ -1596,6 +1574,14 @@ class SmartSearchBar extends Component<Props, State> {
             onClick={this.onAutoComplete}
             loading={loading}
             searchSubstring={searchTerm}
+            runQuickAction={this.runQuickAction}
+            visibleActions={quickActions.filter(
+              action =>
+                action.hotkeys &&
+                (!action.canRunAction ||
+                  action.canRunAction(this.cursorToken, this.filterTokens.length))
+            )}
+            maxMenuHeight={maxMenuHeight}
           />
         )}
       </Container>

+ 91 - 4
static/app/components/smartSearchBar/searchDropdown.tsx

@@ -1,11 +1,15 @@
 import {Fragment, PureComponent} from 'react';
 import styled from '@emotion/styled';
+import color from 'color';
 
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 
-import {ItemType, SearchGroup, SearchItem} from './types';
+import Button from '../button';
+import HotkeysLabel from '../hotkeysLabel';
+
+import {ItemType, QuickAction, SearchGroup, SearchItem} from './types';
 
 type Props = {
   items: SearchGroup[];
@@ -13,6 +17,9 @@ type Props = {
   onClick: (value: string, item: SearchItem) => void;
   searchSubstring: string;
   className?: string;
+  maxMenuHeight?: number;
+  runQuickAction?: (action: QuickAction) => void;
+  visibleActions?: QuickAction[];
 };
 
 class SearchDropdown extends PureComponent<Props> {
@@ -75,7 +82,8 @@ class SearchDropdown extends PureComponent<Props> {
   );
 
   render() {
-    const {className, loading, items} = this.props;
+    const {className, loading, items, runQuickAction, visibleActions, maxMenuHeight} =
+      this.props;
     return (
       <StyledSearchDropdown className={className}>
         {loading ? (
@@ -83,7 +91,7 @@ class SearchDropdown extends PureComponent<Props> {
             <LoadingIndicator mini />
           </LoadingWrapper>
         ) : (
-          <SearchItemsList>
+          <SearchItemsList maxMenuHeight={maxMenuHeight}>
             {items.map(item => {
               const isEmpty = item.children && !item.children.length;
               const invalidTag = item.type === ItemType.INVALID_TAG;
@@ -100,6 +108,30 @@ class SearchDropdown extends PureComponent<Props> {
             })}
           </SearchItemsList>
         )}
+        <DropdownFooter>
+          <ActionsRow>
+            {runQuickAction &&
+              visibleActions?.map(action => {
+                return (
+                  <ActionButtonContainer
+                    key={action.text}
+                    onClick={() => runQuickAction(action)}
+                  >
+                    <HotkeyGlyphWrapper>
+                      <HotkeysLabel value={action.hotkeys?.display ?? []} />
+                    </HotkeyGlyphWrapper>
+                    <HotkeyTitle>{action.text}</HotkeyTitle>
+                  </ActionButtonContainer>
+                );
+              })}
+          </ActionsRow>
+          <Button
+            size="xsmall"
+            href="https://docs.sentry.io/product/sentry-basics/search/"
+          >
+            Read the docs
+          </Button>
+        </DropdownFooter>
       </StyledSearchDropdown>
     );
   }
@@ -164,10 +196,22 @@ const SearchDropdownGroupTitle = styled('header')`
   }
 `;
 
-const SearchItemsList = styled('ul')`
+const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
   padding-left: 0;
   list-style: none;
   margin-bottom: 0;
+  ${p => {
+    if (p.maxMenuHeight !== undefined) {
+      return `
+        max-height: ${p.maxMenuHeight}px;
+        overflow-y: scroll;
+      `;
+    }
+
+    return `
+      height: auto;
+    `;
+  }}
 `;
 
 const SearchListItem = styled(ListItem)`
@@ -202,3 +246,46 @@ const Documentation = styled('span')`
   float: right;
   color: ${p => p.theme.gray300};
 `;
+
+const DropdownFooter = styled(`div`)`
+  width: 100%;
+  min-height: 45px;
+  background-color: ${p => p.theme.backgroundSecondary};
+  border-top: 1px solid ${p => p.theme.innerBorder};
+  flex-direction: row;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: ${space(1)};
+  flex-wrap: wrap;
+`;
+
+const ActionsRow = styled('div')`
+  flex-direction: row;
+  display: flex;
+  align-items: center;
+`;
+
+const ActionButtonContainer = styled('div')`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  height: auto;
+  padding: 0 ${space(1.5)};
+
+  cursor: pointer;
+
+  :hover {
+    border-radius: ${p => p.theme.borderRadius};
+    background-color: ${p => color(p.theme.hover).darken(0.02).string()};
+  }
+`;
+
+const HotkeyGlyphWrapper = styled('span')`
+  color: ${p => p.theme.gray300};
+  margin-right: ${space(0.5)};
+`;
+
+const HotkeyTitle = styled(`span`)`
+  font-size: ${p => p.theme.fontSizeSmall};
+`;

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

@@ -53,7 +53,7 @@ export type QuickAction = {
   actionType: QuickActionType;
   text: string;
   canRunAction?: (
-    token: TokenResult<any> | undefined,
+    token: TokenResult<any> | null | undefined,
     filterTokenCount: number
   ) => boolean;
   hotkeys?: {

+ 4 - 4
static/app/components/smartSearchBar/utils.tsx

@@ -276,25 +276,25 @@ export const quickActions: QuickAction[] = [
     },
   },
   {
-    text: 'Previous Token',
+    text: 'Previous',
     actionType: QuickActionType.Previous,
     hotkeys: {
       actual: ['option+left'],
       display: 'option+left',
     },
     canRunAction: (tok, count) => {
-      return count > 1 || tok?.type !== Token.Filter;
+      return count > 1 || (count > 0 && tok?.type !== Token.Filter);
     },
   },
   {
-    text: 'Next Token',
+    text: 'Next',
     actionType: QuickActionType.Next,
     hotkeys: {
       actual: ['option+right'],
       display: 'option+right',
     },
     canRunAction: (tok, count) => {
-      return count > 1 || tok?.type !== Token.Filter;
+      return count > 1 || (count > 0 && tok?.type !== Token.Filter);
     },
   },
 ];

+ 8 - 26
tests/js/spec/components/smartSearchBar/index.spec.jsx

@@ -569,8 +569,8 @@ describe('SmartSearchBar', function () {
       await tick();
       wrapper.update();
       expect(searchBar.state.searchTerm).toEqual('fu');
-      // 2 items because of headers ("Tags", "Quick Actions")
-      expect(searchBar.state.searchGroups).toHaveLength(2);
+      // 2 items because of headers ("Tags")
+      expect(searchBar.state.searchGroups).toHaveLength(1);
       expect(searchBar.state.activeSearchItem).toEqual(-1);
     });
 
@@ -657,8 +657,8 @@ describe('SmartSearchBar', function () {
       searchBar.updateAutoCompleteItems();
       await tick();
       wrapper.update();
-      // three search groups because of operator suggestions
-      expect(searchBar.state.searchGroups).toHaveLength(3);
+      // two search groups because of operator suggestions
+      expect(searchBar.state.searchGroups).toHaveLength(2);
       expect(searchBar.state.activeSearchItem).toEqual(-1);
     });
 
@@ -677,15 +677,15 @@ describe('SmartSearchBar', function () {
       searchBar.updateAutoCompleteItems();
       await tick();
       wrapper.update();
-      // three search groups tags, values and quick actions
-      expect(searchBar.state.searchGroups).toHaveLength(3);
+      // two search groups tags and values
+      expect(searchBar.state.searchGroups).toHaveLength(2);
       expect(searchBar.state.activeSearchItem).toEqual(-1);
       mockCursorPosition(searchBar, 1);
       searchBar.updateAutoCompleteItems();
       await tick();
       wrapper.update();
-      // two search group because showing tags and quick actions now
-      expect(searchBar.state.searchGroups).toHaveLength(2);
+      // one search group because showing tags
+      expect(searchBar.state.searchGroups).toHaveLength(1);
       expect(searchBar.state.activeSearchItem).toEqual(-1);
     });
 
@@ -878,24 +878,6 @@ describe('SmartSearchBar', function () {
   });
 
   describe('quick actions', () => {
-    it('displays correct quick actions', async () => {
-      const props = {
-        query: 'is:unresolved sdk.name:sentry-cocoa has:key',
-        organization,
-        location,
-        supportedTags,
-      };
-      const searchBar = mountWithTheme(<SmartSearchBar {...props} />, options).instance();
-      searchBar.updateAutoCompleteItems();
-
-      mockCursorPosition(searchBar, 17);
-
-      await tick();
-      expect(searchBar.state.searchGroups).toHaveLength(2);
-      expect(searchBar.state.searchGroups[1].title).toEqual('Quick Actions');
-      expect(searchBar.state.searchGroups[1].children).toHaveLength(4);
-    });
-
     it('delete first token', async () => {
       const props = {
         query: 'is:unresolved sdk.name:sentry-cocoa has:key',