Browse Source

ref(search): Abstract search actions out of the SmartSearchBar (#26955)

Evan Purkhiser 3 years ago
parent
commit
2193063971

+ 209 - 0
static/app/components/smartSearchBar/actions.tsx

@@ -0,0 +1,209 @@
+import {browserHistory, withRouter, WithRouterProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import {openModal} from 'app/actionCreators/modal';
+import {pinSearch, unpinSearch} from 'app/actionCreators/savedSearches';
+import Access from 'app/components/acl/access';
+import Button from 'app/components/button';
+import MenuItem from 'app/components/menuItem';
+import {IconAdd, IconPin, IconSliders} from 'app/icons';
+import {t} from 'app/locale';
+import {SavedSearch, SavedSearchType} from 'app/types';
+import {trackAnalyticsEvent} from 'app/utils/analytics';
+import CreateSavedSearchModal from 'app/views/issueList/createSavedSearchModal';
+
+import SmartSearchBar from './index';
+import {removeSpace} from './utils';
+
+type SmartSearchBarProps = React.ComponentProps<typeof SmartSearchBar>;
+
+type ActionItem = NonNullable<SmartSearchBarProps['actionBarItems']>[number];
+type ActionProps = React.ComponentProps<ActionItem['Action']>;
+
+type PinSearchActionOpts = {
+  /**
+   * The currently pinned search
+   */
+  pinnedSearch?: SavedSearch;
+  /**
+   * The current issue sort
+   */
+  sort: string;
+};
+
+/**
+ * The Pin Search action toggles the current as a pinned search
+ */
+export function makePinSearchAction({pinnedSearch, sort}: PinSearchActionOpts) {
+  const PinSearchAction = ({
+    menuItemVariant,
+    savedSearchType,
+    organization,
+    api,
+    query,
+    location,
+  }: ActionProps & WithRouterProps) => {
+    const onTogglePinnedSearch = async (evt: React.MouseEvent) => {
+      evt.preventDefault();
+      evt.stopPropagation();
+
+      if (savedSearchType === undefined) {
+        return;
+      }
+
+      const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
+
+      trackAnalyticsEvent({
+        eventKey: 'search.pin',
+        eventName: 'Search: Pin',
+        organization_id: organization.id,
+        action: !!pinnedSearch ? 'unpin' : 'pin',
+        search_type: savedSearchType === SavedSearchType.ISSUE ? 'issues' : 'events',
+        query: pinnedSearch?.query ?? query,
+      });
+
+      if (!!pinnedSearch) {
+        unpinSearch(api, organization.slug, savedSearchType, pinnedSearch).then(() => {
+          browserHistory.push({
+            ...location,
+            pathname: `/organizations/${organization.slug}/issues/`,
+            query: {
+              ...currentQuery,
+              query: pinnedSearch.query,
+              sort: pinnedSearch.sort,
+            },
+          });
+        });
+        return;
+      }
+
+      const resp = await pinSearch(
+        api,
+        organization.slug,
+        savedSearchType,
+        removeSpace(query),
+        sort
+      );
+
+      if (!resp || !resp.id) {
+        return;
+      }
+
+      browserHistory.push({
+        ...location,
+        pathname: `/organizations/${organization.slug}/issues/searches/${resp.id}/`,
+        query: currentQuery,
+      });
+    };
+
+    const pinTooltip = !!pinnedSearch ? t('Unpin this search') : t('Pin this search');
+
+    return menuItemVariant ? (
+      <MenuItem withBorder data-test-id="pin-icon" onClick={onTogglePinnedSearch}>
+        <IconPin isSolid={!!pinnedSearch} size="xs" />
+        {!!pinnedSearch ? t('Unpin Search') : t('Pin Search')}
+      </MenuItem>
+    ) : (
+      <ActionButton
+        title={pinTooltip}
+        disabled={!query}
+        aria-label={pinTooltip}
+        onClick={onTogglePinnedSearch}
+        isActive={!!pinnedSearch}
+        data-test-id="pin-icon"
+        icon={<IconPin isSolid={!!pinnedSearch} size="xs" />}
+      />
+    );
+  };
+
+  return {key: 'pinSearch', Action: withRouter(PinSearchAction)};
+}
+
+type SaveSearchActionOpts = {
+  /**
+   * The current issue sort
+   */
+  sort: string;
+};
+
+/**
+ * The Save Search action triggers the create saved search modal from the
+ * current query.
+ */
+export function makeSaveSearchAction({sort}: SaveSearchActionOpts) {
+  const SavedSearchAction = ({menuItemVariant, query, organization}: ActionProps) => {
+    const onClick = () =>
+      openModal(deps => (
+        <CreateSavedSearchModal {...deps} {...{organization, query, sort}} />
+      ));
+
+    return (
+      <Access organization={organization} access={['org:write']}>
+        {menuItemVariant ? (
+          <MenuItem withBorder onClick={onClick}>
+            <IconAdd size="xs" />
+            {t('Create Saved Search')}
+          </MenuItem>
+        ) : (
+          <ActionButton
+            onClick={onClick}
+            data-test-id="save-current-search"
+            icon={<IconAdd size="xs" />}
+            title={t('Add to organization saved searches')}
+            aria-label={t('Add to organization saved searches')}
+          />
+        )}
+      </Access>
+    );
+  };
+
+  return {key: 'saveSearch', Action: SavedSearchAction};
+}
+
+type SearchBuilderActionOpts = {
+  onSidebarToggle: React.MouseEventHandler;
+};
+
+/**
+ * The Search Builder action toggles the Issue Stream search builder
+ */
+export function makeSearchBuilderAction({onSidebarToggle}: SearchBuilderActionOpts) {
+  const SearchBuilderAction = ({menuItemVariant}: ActionProps) =>
+    menuItemVariant ? (
+      <MenuItem withBorder onClick={onSidebarToggle}>
+        <IconSliders size="xs" />
+        {t('Toggle sidebar')}
+      </MenuItem>
+    ) : (
+      <ActionButton
+        title={t('Toggle search builder')}
+        tooltipProps={{containerDisplayMode: 'inline-flex'}}
+        aria-label={t('Toggle search builder')}
+        onClick={onSidebarToggle}
+        icon={<IconSliders size="xs" />}
+      />
+    );
+
+  return {key: 'searchBuilder', Action: SearchBuilderAction};
+}
+
+export const ActionButton = styled(Button)<{isActive?: boolean}>`
+  color: ${p => (p.isActive ? p.theme.blue300 : p.theme.gray300)};
+  width: 18px;
+
+  &,
+  &:hover,
+  &:focus {
+    background: transparent;
+  }
+
+  &:hover {
+    color: ${p => p.theme.gray400};
+  }
+`;
+
+ActionButton.defaultProps = {
+  type: 'button',
+  borderless: true,
+  size: 'zero',
+};

+ 195 - 399
static/app/components/smartSearchBar/index.tsx

@@ -1,21 +1,14 @@
 import * as React from 'react';
 import TextareaAutosize from 'react-autosize-textarea';
-import {browserHistory, withRouter, WithRouterProps} from 'react-router';
+import {withRouter, WithRouterProps} from 'react-router';
 import isPropValid from '@emotion/is-prop-valid';
-import {ClassNames, withTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import debounce from 'lodash/debounce';
 
 import {addErrorMessage} from 'app/actionCreators/indicator';
-import {
-  fetchRecentSearches,
-  pinSearch,
-  saveRecentSearch,
-  unpinSearch,
-} from 'app/actionCreators/savedSearches';
+import {fetchRecentSearches, saveRecentSearch} from 'app/actionCreators/savedSearches';
 import {Client} from 'app/api';
-import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
 import DropdownLink from 'app/components/dropdownLink';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
@@ -25,125 +18,77 @@ import {
   MAX_AUTOCOMPLETE_RELEASES,
   NEGATION_OPERATOR,
 } from 'app/constants';
-import {IconClose, IconEllipsis, IconPin, IconSearch, IconSliders} from 'app/icons';
+import {IconClose, IconEllipsis, IconSearch} from 'app/icons';
 import {t} from 'app/locale';
 import MemberListStore from 'app/stores/memberListStore';
 import space from 'app/styles/space';
-import {
-  LightWeightOrganization,
-  SavedSearch,
-  SavedSearchType,
-  Tag,
-  User,
-} from 'app/types';
+import {LightWeightOrganization, SavedSearchType, Tag, User} from 'app/types';
 import {defined} from 'app/utils';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import {callIfFunction} from 'app/utils/callIfFunction';
-import commonTheme, {Theme} from 'app/utils/theme';
 import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
-import CreateSavedSearchButton from 'app/views/issueList/createSavedSearchButton';
 
+import {ActionButton} from './actions';
 import SearchDropdown from './searchDropdown';
 import {ItemType, SearchGroup, SearchItem} from './types';
 import {
   addSpace,
   createSearchGroups,
   filterSearchGroupsByIndex,
+  getLastTermIndex,
+  getQueryTerms,
   removeSpace,
 } from './utils';
 
 const DROPDOWN_BLUR_DURATION = 200;
 
-const getMediaQuery = (size: string, type: React.CSSProperties['display']) => `
-  display: ${type};
+/**
+ * The max width in pixels of the search bar at which the buttons will
+ * have overflowed into the dropdown.
+ */
+const ACTION_OVERFLOW_WIDTH = 400;
 
-  @media (min-width: ${size}) {
-    display: ${type === 'none' ? 'block' : 'none'};
-  }
-`;
+/**
+ * Actions are moved to the overflow dropdown after each pixel step is reached.
+ */
+const ACTION_OVERFLOW_STEPS = 75;
 
-const getInputButtonStyles = (p: {
-  isActive?: boolean;
-  collapseIntoEllipsisMenu?: number;
-}) => `
-  color: ${p.isActive ? commonTheme.blue300 : commonTheme.gray300};
-  width: 18px;
-
-  &,
-  &:hover,
-  &:focus {
-    background: transparent;
-  }
-
-  &:hover {
-    color: ${commonTheme.gray400};
-  }
+/**
+ * Is the SearchItem a default item
+ */
+const isDefaultDropdownItem = (item: SearchItem) => item?.type === 'default';
 
-  ${
-    p.collapseIntoEllipsisMenu &&
-    getMediaQuery(commonTheme.breakpoints[p.collapseIntoEllipsisMenu], 'none')
-  };
-`;
-
-type DropdownElementStylesProps = {
-  theme: Theme;
-  showBelowMediaQuery: number;
-  last?: boolean;
+type ActionProps = {
+  api: Client;
+  /**
+   * Render the actions as a menu item
+   */
+  menuItemVariant?: boolean;
+  /**
+   * The current query
+   */
+  query: string;
+  /**
+   * The organization
+   */
+  organization: LightWeightOrganization;
+  /**
+   * The saved search type passed to the search bar
+   */
+  savedSearchType?: SavedSearchType;
 };
 
-const getDropdownElementStyles = (p: DropdownElementStylesProps) => `
-  padding: 0 ${space(1)} ${p.last ? null : space(0.5)};
-  margin-bottom: ${p.last ? null : space(0.5)};
-  display: none;
-  color: ${p.theme.textColor};
-  align-items: center;
-  min-width: 190px;
-  height: 38px;
-  padding-left: ${space(1.5)};
-  padding-right: ${space(1.5)};
-
-  &,
-  &:hover,
-  &:focus {
-    border-bottom: ${p.last ? null : `1px solid ${p.theme.border}`};
-    border-radius: 0;
-  }
-
-  &:hover {
-    color: ${p.theme.blue300};
-  }
-  & > svg {
-    margin-right: ${space(1)};
-  }
-
-  ${
-    p.showBelowMediaQuery &&
-    getMediaQuery(commonTheme.breakpoints[p.showBelowMediaQuery], 'flex')
-  }
-`;
-
-const ThemedCreateSavedSearchButton = withTheme(
-  (props: {theme: Theme; query; sort; organization}) => (
-    <ClassNames>
-      {({css}) => (
-        <CreateSavedSearchButton
-          buttonClassName={css`
-            ${getDropdownElementStyles({
-              theme: props.theme,
-              showBelowMediaQuery: 2,
-              last: false,
-            })}
-          `}
-          tooltipClassName={css`
-            ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
-          `}
-          {...props}
-        />
-      )}
-    </ClassNames>
-  )
-);
+type ActionBarItem = {
+  /**
+   * Name of the action
+   */
+  key: string;
+  /**
+   * The action component to render
+   */
+  Action: React.ComponentType<ActionProps>;
+};
 
 type Props = WithRouterProps & {
   api: Client;
@@ -153,13 +98,17 @@ type Props = WithRouterProps & {
 
   defaultQuery?: string;
   query?: string | null;
-  sort?: string;
+  /**
+   * Additional components to render as actions on the right of the search bar
+   */
+  actionBarItems?: ActionBarItem[];
   /**
    * Prepare query value before filtering dropdown items
    */
   prepareQuery?: (query: string) => string;
   /**
-   * Search items to display when there's no tag key. Is a tuple of search items and recent search items
+   * Search items to display when there's no tag key. Is a tuple of search
+   * items and recent search items
    */
   defaultSearchItems?: [SearchItem[], SearchItem[]];
   /**
@@ -187,14 +136,6 @@ type Props = WithRouterProps & {
    * List user's recent searches
    */
   hasRecentSearches?: boolean;
-  /**
-   * Has search builder UI
-   */
-  hasSearchBuilder?: boolean;
-  /**
-   * Can create a saved search
-   */
-  canCreateSavedSearch?: boolean;
   /**
    * Wrap the input with a form. Useful if search bar is used within a parent
    * form
@@ -205,14 +146,6 @@ type Props = WithRouterProps & {
    * the current org
    */
   savedSearchType?: SavedSearchType;
-  /**
-   * Has pinned search feature
-   */
-  hasPinnedSearch?: boolean;
-  /**
-   * The pinned search object
-   */
-  pinnedSearch?: SavedSearch;
   /**
    * Get a list of tag values for the passed tag
    */
@@ -241,14 +174,10 @@ type Props = WithRouterProps & {
    * Called when a recent search is saved
    */
   onSavedRecentSearch?: (query: string) => void;
-  /**
-   * Called when the sidebar is toggled
-   */
-  onSidebarToggle?: React.EventHandler<React.MouseEvent>;
   /**
    * If true, excludes the environment tag from the autocompletion list. This
-   * is because we don't want to treat environment as a tag in some places
-   * such as the stream view where it is a top level concept
+   * is because we don't want to treat environment as a tag in some places such
+   * as the stream view where it is a top level concept
    */
   excludeEnvironment?: boolean;
   /**
@@ -288,30 +217,16 @@ type State = {
    * Index of the focused search item
    */
   activeSearchItem: number;
-  tags: {[key: string]: string};
+  tags: Record<string, string>;
   dropdownVisible: boolean;
   loading: boolean;
-};
-
-class SmartSearchBar extends React.Component<Props, State> {
   /**
-   * Given a query, and the current cursor position, return the string-delimiting
-   * index of the search term designated by the cursor.
+   * The number of actions that are not in the overflow menu.
    */
-  static getLastTermIndex = (query: string, cursor: number) => {
-    // TODO: work with quoted-terms
-    const cursorOffset = query.slice(cursor).search(/\s|$/);
-    return cursor + (cursorOffset === -1 ? 0 : cursorOffset);
-  };
-
-  /**
-   * Returns an array of query terms, including incomplete terms
-   *
-   * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"]
-   */
-  static getQueryTerms = (query: string, cursor: number) =>
-    query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g);
+  numActionsVisible: number;
+};
 
+class SmartSearchBar extends React.Component<Props, State> {
   static defaultProps = {
     defaultQuery: '',
     query: null,
@@ -320,7 +235,6 @@ class SmartSearchBar extends React.Component<Props, State> {
     placeholder: t('Search for events, users, tags, and more'),
     supportedTags: {},
     defaultSearchItems: [[], []],
-    hasPinnedSearch: false,
     useFormWrapper: true,
     savedSearchType: SavedSearchType.ISSUE,
   };
@@ -337,8 +251,22 @@ class SmartSearchBar extends React.Component<Props, State> {
     tags: {},
     dropdownVisible: false,
     loading: false,
+    numActionsVisible: this.props.actionBarItems?.length ?? 0,
   };
 
+  componentDidMount() {
+    if (!window.ResizeObserver) {
+      return;
+    }
+
+    if (this.containerRef.current === null) {
+      return;
+    }
+
+    this.inputResizeObserver = new ResizeObserver(this.updateActionsVisible);
+    this.inputResizeObserver.observe(this.containerRef.current);
+  }
+
   componentDidUpdate(prevProps: Props) {
     const {query} = this.props;
     const {query: lastQuery} = prevProps;
@@ -350,9 +278,10 @@ class SmartSearchBar extends React.Component<Props, State> {
   }
 
   componentWillUnmount() {
+    this.inputResizeObserver?.disconnect();
+
     if (this.blurTimeout) {
       clearTimeout(this.blurTimeout);
-      this.blurTimeout = undefined;
     }
   }
 
@@ -360,35 +289,54 @@ class SmartSearchBar extends React.Component<Props, State> {
    * Tracks the dropdown blur
    */
   blurTimeout?: number;
+
   /**
    * Ref to the search element itself
    */
   searchInput = React.createRef<HTMLTextAreaElement>();
 
-  blur = () => {
-    if (!this.searchInput.current) {
+  /**
+   * Ref to the search container
+   */
+  containerRef = React.createRef<HTMLDivElement>();
+
+  /**
+   * Used to determine when actions should be moved to the action overflow menu
+   */
+  inputResizeObserver: ResizeObserver | null = null;
+
+  /**
+   * Updates the numActionsVisible count as the search bar is resized
+   */
+  updateActionsVisible = (entries: ResizeObserverEntry[]) => {
+    if (entries.length === 0) {
       return;
     }
-    this.searchInput.current.blur();
-  };
 
-  onSubmit = (evt: React.FormEvent) => {
-    const {organization, savedSearchType} = this.props;
-    evt.preventDefault();
+    const entry = entries[0];
+    const {width} = entry.contentRect;
+    const actionCount = this.props.actionBarItems?.length ?? 0;
 
-    trackAnalyticsEvent({
-      eventKey: 'search.searched',
-      eventName: 'Search: Performed search',
-      organization_id: organization.id,
-      query: removeSpace(this.state.query),
-      search_type: savedSearchType === 0 ? 'issues' : 'events',
-      search_source: 'main_search',
-    });
+    const numActionsVisible = Math.min(
+      actionCount,
+      Math.floor(Math.max(0, width - ACTION_OVERFLOW_WIDTH) / ACTION_OVERFLOW_STEPS)
+    );
 
-    this.doSearch();
+    if (this.state.numActionsVisible === numActionsVisible) {
+      return;
+    }
+
+    this.setState({numActionsVisible});
   };
 
-  doSearch = async () => {
+  blur() {
+    if (!this.searchInput.current) {
+      return;
+    }
+    this.searchInput.current.blur();
+  }
+
+  async doSearch() {
     const {onSearch, onSavedRecentSearch, api, organization, savedSearchType} =
       this.props;
     this.blur();
@@ -411,6 +359,22 @@ class SmartSearchBar extends React.Component<Props, State> {
       // Silently capture errors if it fails to save
       Sentry.captureException(err);
     }
+  }
+
+  onSubmit = (evt: React.FormEvent) => {
+    const {organization, savedSearchType} = this.props;
+    evt.preventDefault();
+
+    trackAnalyticsEvent({
+      eventKey: 'search.searched',
+      eventName: 'Search: Performed search',
+      organization_id: organization.id,
+      query: removeSpace(this.state.query),
+      search_type: savedSearchType === 0 ? 'issues' : 'events',
+      search_source: 'main_search',
+    });
+
+    this.doSearch();
   };
 
   clearSearch = () =>
@@ -522,7 +486,7 @@ class SmartSearchBar extends React.Component<Props, State> {
         childrenIndex !== undefined &&
         searchGroups[groupIndex].children[childrenIndex];
 
-      if (item && !this.isDefaultDropdownItem(item)) {
+      if (item && !isDefaultDropdownItem(item)) {
         this.onAutoComplete(item.value, item);
       }
       return;
@@ -563,17 +527,17 @@ class SmartSearchBar extends React.Component<Props, State> {
     });
   };
 
-  getCursorPosition = () => {
+  getCursorPosition() {
     if (!this.searchInput.current) {
       return -1;
     }
     return this.searchInput.current.selectionStart ?? -1;
-  };
+  }
 
   /**
    * Returns array of possible key values that substring match `query`
    */
-  getTagKeys = (query: string): SearchItem[] => {
+  getTagKeys(query: string): SearchItem[] {
     const {prepareQuery} = this.props;
 
     const supportedTags = this.props.supportedTags ?? {};
@@ -594,7 +558,7 @@ class SmartSearchBar extends React.Component<Props, State> {
     }
 
     return tagKeys.map(value => ({value, desc: value}));
-  };
+  }
 
   /**
    * Returns array of tag values that substring match `query`; invokes `callback`
@@ -787,8 +751,8 @@ class SmartSearchBar extends React.Component<Props, State> {
 
     this.setState({previousQuery: query});
 
-    const lastTermIndex = SmartSearchBar.getLastTermIndex(query, cursor);
-    const terms = SmartSearchBar.getQueryTerms(query, lastTermIndex);
+    const lastTermIndex = getLastTermIndex(query, cursor);
+    const terms = getQueryTerms(query, lastTermIndex);
 
     if (
       !terms || // no terms
@@ -904,8 +868,6 @@ class SmartSearchBar extends React.Component<Props, State> {
     return;
   };
 
-  isDefaultDropdownItem = (item: SearchItem) => item && item.type === 'default';
-
   /**
    * Updates autocomplete dropdown items and autocomplete index state
    *
@@ -914,12 +876,12 @@ class SmartSearchBar extends React.Component<Props, State> {
    * @param tagName The current tag name in scope
    * @param type Defines the type/state of the dropdown menu items
    */
-  updateAutoCompleteState = (
+  updateAutoCompleteState(
     searchItems: SearchItem[],
     recentSearchItems: SearchItem[],
     tagName: string,
     type: ItemType
-  ) => {
+  ) {
     const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
     const query = this.state.query;
 
@@ -936,71 +898,7 @@ class SmartSearchBar extends React.Component<Props, State> {
         queryCharsLeft
       )
     );
-  };
-
-  onTogglePinnedSearch = async (evt: React.MouseEvent) => {
-    const {
-      api,
-      location,
-      organization,
-      savedSearchType,
-      hasPinnedSearch,
-      pinnedSearch,
-      sort,
-    } = this.props;
-
-    evt.preventDefault();
-    evt.stopPropagation();
-
-    if (savedSearchType === undefined || !hasPinnedSearch) {
-      return;
-    }
-
-    // eslint-disable-next-line no-unused-vars
-    const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
-
-    trackAnalyticsEvent({
-      eventKey: 'search.pin',
-      eventName: 'Search: Pin',
-      organization_id: organization.id,
-      action: !!pinnedSearch ? 'unpin' : 'pin',
-      search_type: savedSearchType === 0 ? 'issues' : 'events',
-      query: pinnedSearch?.query ?? this.state.query,
-    });
-
-    if (!!pinnedSearch) {
-      unpinSearch(api, organization.slug, savedSearchType, pinnedSearch).then(() => {
-        browserHistory.push({
-          ...location,
-          pathname: `/organizations/${organization.slug}/issues/`,
-          query: {
-            ...currentQuery,
-            query: pinnedSearch.query,
-            sort: pinnedSearch.sort,
-          },
-        });
-      });
-      return;
-    }
-
-    const resp = await pinSearch(
-      api,
-      organization.slug,
-      savedSearchType,
-      removeSpace(this.state.query),
-      sort
-    );
-
-    if (!resp || !resp.id) {
-      return;
-    }
-
-    browserHistory.push({
-      ...location,
-      pathname: `/organizations/${organization.slug}/issues/searches/${resp.id}/`,
-      query: currentQuery,
-    });
-  };
+  }
 
   onAutoComplete = (replaceText: string, item: SearchItem) => {
     if (item.type === 'recent-search') {
@@ -1024,8 +922,8 @@ class SmartSearchBar extends React.Component<Props, State> {
     const cursor = this.getCursorPosition();
     const query = this.state.query;
 
-    const lastTermIndex = SmartSearchBar.getLastTermIndex(query, cursor);
-    const terms = SmartSearchBar.getQueryTerms(query, lastTermIndex);
+    const lastTermIndex = getLastTermIndex(query, cursor);
+    const terms = getQueryTerms(query, lastTermIndex);
     let newQuery: string;
 
     // If not postfixed with : (tag value), add trailing space
@@ -1080,25 +978,21 @@ class SmartSearchBar extends React.Component<Props, State> {
 
   render() {
     const {
+      api,
       className,
+      savedSearchType,
       dropdownClassName,
+      actionBarItems,
       organization,
-      hasPinnedSearch,
-      hasSearchBuilder,
-      canCreateSavedSearch,
-      pinnedSearch,
       placeholder,
       disabled,
       useFormWrapper,
-      onSidebarToggle,
       inlineLabel,
-      sort,
       maxQueryLength,
     } = this.props;
 
-    const pinTooltip = !!pinnedSearch ? t('Unpin this search') : t('Pin this search');
-    const pinIcon = <IconPin isSolid={!!pinnedSearch} size="xs" />;
-    const hasQuery = !!this.state.query;
+    const {query, searchGroups, searchTerm, dropdownVisible, numActionsVisible, loading} =
+      this.state;
 
     const hasSyntaxHighlight = organization.features.includes('search-syntax-highlight');
 
@@ -1110,7 +1004,7 @@ class SmartSearchBar extends React.Component<Props, State> {
         name="query"
         ref={this.searchInput}
         autoComplete="off"
-        value={this.state.query}
+        value={query}
         onFocus={this.onQueryFocus}
         onBlur={this.onQueryBlur}
         onKeyUp={this.onKeyUp}
@@ -1124,151 +1018,69 @@ class SmartSearchBar extends React.Component<Props, State> {
       />
     );
 
+    // Segment actions into visible and overflowed groups
+    const actionItems = actionBarItems ?? [];
+    const actionProps = {
+      api,
+      organization,
+      query,
+      savedSearchType,
+    };
+
+    const visibleActions = actionItems
+      .slice(0, numActionsVisible)
+      .map(({key, Action}) => <Action key={key} {...actionProps} />);
+
+    const overflowedActions = actionItems
+      .slice(numActionsVisible)
+      .map(({key, Action}) => <Action key={key} {...actionProps} menuItemVariant />);
+
     return (
-      <Container className={className} isOpen={this.state.dropdownVisible}>
+      <Container ref={this.containerRef} className={className} isOpen={dropdownVisible}>
         <SearchLabel htmlFor="smart-search-input" aria-label={t('Search events')}>
           <IconSearch />
           {inlineLabel}
         </SearchLabel>
 
         <InputWrapper>
-          {hasSyntaxHighlight && <Highlight>{renderQuery(this.state.query)}</Highlight>}
+          {hasSyntaxHighlight && <Highlight>{renderQuery(query)}</Highlight>}
           {useFormWrapper ? <form onSubmit={this.onSubmit}>{input}</form> : input}
         </InputWrapper>
+
         <ActionsBar gap={0.5}>
-          {this.state.query !== '' && (
-            <InputButton
-              type="button"
-              title={t('Clear search')}
-              borderless
-              aria-label="Clear search"
-              size="zero"
-              tooltipProps={{
-                containerDisplayMode: 'inline-flex',
-              }}
+          {query !== '' && (
+            <ActionButton
               onClick={this.clearSearch}
-            >
-              <IconClose size="xs" />
-            </InputButton>
-          )}
-          {hasPinnedSearch && (
-            <ClassNames>
-              {({css}) => (
-                <InputButton
-                  type="button"
-                  title={pinTooltip}
-                  borderless
-                  disabled={!hasQuery}
-                  aria-label={pinTooltip}
-                  size="zero"
-                  tooltipProps={{
-                    containerDisplayMode: 'inline-flex',
-                    className: css`
-                      ${getMediaQuery(commonTheme.breakpoints[1], 'none')}
-                    `,
-                  }}
-                  onClick={this.onTogglePinnedSearch}
-                  collapseIntoEllipsisMenu={1}
-                  isActive={!!pinnedSearch}
-                  icon={pinIcon}
-                />
-              )}
-            </ClassNames>
-          )}
-          {canCreateSavedSearch && (
-            <ClassNames>
-              {({css}) => (
-                <CreateSavedSearchButton
-                  query={this.state.query}
-                  sort={sort}
-                  organization={organization}
-                  withTooltip
-                  iconOnly
-                  buttonClassName={css`
-                    ${getInputButtonStyles({
-                      collapseIntoEllipsisMenu: 2,
-                    })}
-                  `}
-                  tooltipClassName={css`
-                    ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
-                  `}
-                />
-              )}
-            </ClassNames>
-          )}
-          {hasSearchBuilder && (
-            <ClassNames>
-              {({css}) => (
-                <InputButton
-                  title={t('Toggle search builder')}
-                  borderless
-                  size="zero"
-                  tooltipProps={{
-                    containerDisplayMode: 'inline-flex',
-                    className: css`
-                      ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
-                    `,
-                  }}
-                  collapseIntoEllipsisMenu={2}
-                  aria-label={t('Toggle search builder')}
-                  onClick={onSidebarToggle}
-                  icon={<IconSliders size="xs" />}
-                />
-              )}
-            </ClassNames>
+              icon={<IconClose size="xs" />}
+              title={t('Clear search')}
+              aria-label={t('Clear search')}
+            />
           )}
-
-          {(hasPinnedSearch || canCreateSavedSearch || hasSearchBuilder) && (
-            <StyledDropdownLink
+          {visibleActions}
+          {overflowedActions.length > 0 && (
+            <DropdownLink
               anchorRight
               caret={false}
               title={
-                <InputButton
-                  size="zero"
-                  borderless
-                  tooltipProps={{
-                    containerDisplayMode: 'flex',
-                  }}
-                  type="button"
+                <ActionButton
                   aria-label={t('Show more')}
                   icon={<VerticalEllipsisIcon size="xs" />}
                 />
               }
             >
-              {hasPinnedSearch && (
-                <DropdownElement
-                  showBelowMediaQuery={1}
-                  data-test-id="pin-icon"
-                  onClick={this.onTogglePinnedSearch}
-                >
-                  {pinIcon}
-                  {!!pinnedSearch ? t('Unpin Search') : t('Pin Search')}
-                </DropdownElement>
-              )}
-              {canCreateSavedSearch && (
-                <ThemedCreateSavedSearchButton
-                  query={this.state.query}
-                  organization={organization}
-                  sort={sort}
-                />
-              )}
-              {hasSearchBuilder && (
-                <DropdownElement showBelowMediaQuery={2} last onClick={onSidebarToggle}>
-                  <IconSliders size="xs" />
-                  {t('Toggle sidebar')}
-                </DropdownElement>
-              )}
-            </StyledDropdownLink>
+              {overflowedActions}
+            </DropdownLink>
           )}
         </ActionsBar>
-        {(this.state.loading || this.state.searchGroups.length > 0) && (
+
+        {(loading || searchGroups.length > 0) && (
           <SearchDropdown
-            css={{display: this.state.dropdownVisible ? 'block' : 'none'}}
+            css={{display: dropdownVisible ? 'block' : 'none'}}
             className={dropdownClassName}
-            items={this.state.searchGroups}
+            items={searchGroups}
             onClick={this.onAutoComplete}
-            loading={this.state.loading}
-            searchSubstring={this.state.searchTerm}
+            loading={loading}
+            searchSubstring={searchTerm}
           />
         )}
       </Container>
@@ -1324,6 +1136,13 @@ const Container = styled('div')<{isOpen: boolean}>`
   }
 `;
 
+const SearchLabel = styled('label')`
+  display: flex;
+  padding: ${space(0.5)} 0;
+  margin: 0;
+  color: ${p => p.theme.gray300};
+`;
+
 const InputWrapper = styled('div')`
   position: relative;
 `;
@@ -1372,34 +1191,11 @@ const SearchInput = styled(TextareaAutosize, {
   }
 `;
 
-const InputButton = styled(Button)`
-  ${getInputButtonStyles}
-`;
-
-const StyledDropdownLink = styled(DropdownLink)`
-  display: none;
-
-  @media (max-width: ${commonTheme.breakpoints[2]}) {
-    display: flex;
-  }
-`;
-
-const DropdownElement = styled('a')<Omit<DropdownElementStylesProps, 'theme'>>`
-  ${getDropdownElementStyles}
+const ActionsBar = styled(ButtonBar)`
+  height: ${space(2)};
+  margin: ${space(0.5)} 0;
 `;
 
 const VerticalEllipsisIcon = styled(IconEllipsis)`
   transform: rotate(90deg);
 `;
-
-const SearchLabel = styled('label')`
-  display: flex;
-  padding: ${space(0.5)} 0;
-  margin: 0;
-  color: ${p => p.theme.gray300};
-`;
-
-const ActionsBar = styled(ButtonBar)`
-  height: ${space(2)};
-  margin: ${space(0.5)} 0;
-`;

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

@@ -6,17 +6,36 @@ import {ItemType, SearchGroup, SearchItem} from './types';
 export function addSpace(query = '') {
   if (query.length !== 0 && query[query.length - 1] !== ' ') {
     return query + ' ';
-  } else {
-    return query;
   }
+
+  return query;
 }
 
 export function removeSpace(query = '') {
   if (query[query.length - 1] === ' ') {
     return query.slice(0, query.length - 1);
-  } else {
-    return query;
   }
+
+  return query;
+}
+
+/**
+ * Given a query, and the current cursor position, return the string-delimiting
+ * index of the search term designated by the cursor.
+ */
+export function getLastTermIndex(query: string, cursor: number) {
+  // TODO: work with quoted-terms
+  const cursorOffset = query.slice(cursor).search(/\s|$/);
+  return cursor + (cursorOffset === -1 ? 0 : cursorOffset);
+}
+
+/**
+ * Returns an array of query terms, including incomplete terms
+ *
+ * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"]
+ */
+export function getQueryTerms(query: string, cursor: number) {
+  return query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g);
 }
 
 function getTitleForType(type: ItemType) {

+ 0 - 50
static/app/views/issueList/createSavedSearchButton.tsx

@@ -1,50 +0,0 @@
-import {openModal} from 'app/actionCreators/modal';
-import Access from 'app/components/acl/access';
-import Button from 'app/components/button';
-import {IconAdd} from 'app/icons';
-import {t} from 'app/locale';
-import {LightWeightOrganization} from 'app/types';
-
-import CreateSavedSearchModal from './createSavedSearchModal';
-
-type Props = {
-  query: string;
-  sort?: string;
-  organization: LightWeightOrganization;
-  buttonClassName?: string;
-  tooltipClassName?: string;
-  iconOnly?: boolean;
-  withTooltip?: boolean;
-};
-
-const CreateSavedSearchButton = ({
-  buttonClassName,
-  tooltipClassName,
-  withTooltip,
-  iconOnly,
-  organization,
-  ...rest
-}: Props) => (
-  <Access organization={organization} access={['org:write']}>
-    <Button
-      title={withTooltip ? t('Add to organization saved searches') : undefined}
-      onClick={() =>
-        openModal(deps => (
-          <CreateSavedSearchModal organization={organization} {...rest} {...deps} />
-        ))
-      }
-      data-test-id="save-current-search"
-      size="zero"
-      borderless
-      type="button"
-      aria-label={t('Add to organization saved searches')}
-      icon={<IconAdd size="xs" />}
-      className={buttonClassName}
-      tooltipProps={{className: tooltipClassName}}
-    >
-      {!iconOnly && t('Create Saved Search')}
-    </Button>
-  </Access>
-);
-
-export default CreateSavedSearchButton;

+ 15 - 6
static/app/views/issueList/searchBar.tsx

@@ -4,6 +4,11 @@ import styled from '@emotion/styled';
 import {fetchRecentSearches} from 'app/actionCreators/savedSearches';
 import {Client} from 'app/api';
 import SmartSearchBar from 'app/components/smartSearchBar';
+import {
+  makePinSearchAction,
+  makeSaveSearchAction,
+  makeSearchBuilderAction,
+} from 'app/components/smartSearchBar/actions';
 import {SearchItem} from 'app/components/smartSearchBar/types';
 import {t} from 'app/locale';
 import {Organization, SavedSearch, SavedSearchType, Tag} from 'app/types';
@@ -51,6 +56,8 @@ type Props = React.ComponentProps<typeof SmartSearchBar> & {
   tagValueLoader: TagValueLoader;
   projectIds?: string[];
   savedSearch?: SavedSearch;
+  onSidebarToggle: (e: React.MouseEvent) => void;
+  sort: string;
 };
 
 type State = {
@@ -115,21 +122,23 @@ class IssueListSearchBar extends React.Component<Props, State> {
   };
 
   render() {
-    const {tagValueLoader: _, savedSearch, onSidebarToggle, ...props} = this.props;
+    const {tagValueLoader: _, savedSearch, sort, onSidebarToggle, ...props} = this.props;
+
+    const pinnedSearch = savedSearch?.isPinned ? savedSearch : undefined;
 
     return (
       <SmartSearchBarNoLeftCorners
-        hasPinnedSearch
         hasRecentSearches
-        hasSearchBuilder
-        canCreateSavedSearch
         maxSearchItems={5}
         savedSearchType={SavedSearchType.ISSUE}
         onGetTagValues={this.getTagValues}
         defaultSearchItems={this.state.defaultSearchItems}
         onSavedRecentSearch={this.handleSavedRecentSearch}
-        onSidebarToggle={onSidebarToggle}
-        pinnedSearch={savedSearch?.isPinned ? savedSearch : undefined}
+        actionBarItems={[
+          makePinSearchAction({sort, pinnedSearch}),
+          makeSaveSearchAction({sort}),
+          makeSearchBuilderAction({onSidebarToggle}),
+        ]}
         {...props}
       />
     );

+ 104 - 0
tests/js/spec/components/smartSearchBar/actions.spec.jsx

@@ -0,0 +1,104 @@
+import {mountWithTheme} from 'sentry-test/enzyme';
+
+import {makePinSearchAction} from 'app/components/smartSearchBar/actions';
+
+describe('SmartSearchBarActions', () => {
+  describe('make', function () {
+    const organization = TestStubs.Organization({id: '123'});
+    const api = new MockApiClient();
+
+    let pinRequest, unpinRequest, location, options;
+
+    beforeEach(function () {
+      location = {
+        pathname: `/organizations/${organization.slug}/recent-searches/`,
+        query: {
+          projectId: '0',
+        },
+      };
+
+      options = TestStubs.routerContext([
+        {
+          organization,
+        },
+      ]);
+
+      pinRequest = MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/pinned-searches/`,
+        method: 'PUT',
+        body: [],
+      });
+      unpinRequest = MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/pinned-searches/`,
+        method: 'DELETE',
+        body: [],
+      });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/recent-searches/`,
+        method: 'POST',
+        body: {},
+      });
+    });
+
+    it('does not pin when query is empty', async function () {
+      const {Action} = makePinSearchAction({sort: ''});
+
+      const wrapper = mountWithTheme(
+        <Action
+          api={api}
+          organization={organization}
+          query=""
+          savedSearchType={0}
+          location={location}
+        />,
+        options
+      );
+      wrapper.find('ActionButton').simulate('click');
+      await wrapper.update();
+
+      expect(pinRequest).not.toHaveBeenCalled();
+    });
+
+    it('adds pins', async function () {
+      const {Action} = makePinSearchAction({sort: ''});
+
+      const wrapper = mountWithTheme(
+        <Action
+          api={api}
+          organization={organization}
+          query="is:unresolved"
+          savedSearchType={0}
+          location={location}
+        />,
+        options
+      );
+      wrapper.find('ActionButton').simulate('click');
+      await wrapper.update();
+
+      expect(pinRequest).toHaveBeenCalled();
+      expect(unpinRequest).not.toHaveBeenCalled();
+    });
+
+    it('removes pins', async function () {
+      const pinnedSearch = TestStubs.Search({isPinned: true});
+      const {Action} = makePinSearchAction({pinnedSearch, sort: ''});
+
+      const wrapper = mountWithTheme(
+        <Action
+          api={api}
+          organization={organization}
+          query="is:unresolved"
+          savedSearchType={0}
+          location={location}
+        />,
+        options
+      );
+
+      wrapper.find('ActionButton').simulate('click');
+      await wrapper.update();
+
+      expect(pinRequest).not.toHaveBeenCalled();
+      expect(unpinRequest).toHaveBeenCalled();
+    });
+  });
+});

+ 0 - 143
tests/js/spec/components/smartSearchBar.spec.jsx → tests/js/spec/components/smartSearchBar/index.spec.jsx

@@ -2,37 +2,8 @@ import {mountWithTheme} from 'sentry-test/enzyme';
 
 import {Client} from 'app/api';
 import {SmartSearchBar} from 'app/components/smartSearchBar';
-import {addSpace, removeSpace} from 'app/components/smartSearchBar/utils';
 import TagStore from 'app/stores/tagStore';
 
-describe('addSpace()', function () {
-  it('should add a space when there is no trailing space', function () {
-    expect(addSpace('one')).toEqual('one ');
-  });
-
-  it('should not add another space when there is already one', function () {
-    expect(addSpace('one ')).toEqual('one ');
-  });
-
-  it('should leave the empty string alone', function () {
-    expect(addSpace('')).toEqual('');
-  });
-});
-
-describe('removeSpace()', function () {
-  it('should remove a trailing space', function () {
-    expect(removeSpace('one ')).toEqual('one');
-  });
-
-  it('should not remove the last character if it is not a space', function () {
-    expect(removeSpace('one')).toEqual('one');
-  });
-
-  it('should leave the empty string alone', function () {
-    expect(removeSpace('')).toEqual('');
-  });
-});
-
 describe('SmartSearchBar', function () {
   let location, options, organization, supportedTags;
   let environmentTagValuesMock;
@@ -290,37 +261,6 @@ describe('SmartSearchBar', function () {
     });
   });
 
-  describe('getQueryTerms()', function () {
-    it('should extract query terms from a query string', function () {
-      let query = 'tagname: ';
-      expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual(['tagname:']);
-
-      query = 'tagname:derp browser:';
-      expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual([
-        'tagname:derp',
-        'browser:',
-      ]);
-
-      query = '   browser:"Chrome 33.0"    ';
-      expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual([
-        'browser:"Chrome 33.0"',
-      ]);
-    });
-  });
-
-  describe('getLastTermIndex()', function () {
-    it('should provide the index of the last query term, given cursor index', function () {
-      let query = 'tagname:';
-      expect(SmartSearchBar.getLastTermIndex(query, 0)).toEqual(8);
-
-      query = 'tagname:foo'; // 'f' (index 9)
-      expect(SmartSearchBar.getLastTermIndex(query, 9)).toEqual(11);
-
-      query = 'tagname:foo anothertag:bar'; // 'f' (index 9)
-      expect(SmartSearchBar.getLastTermIndex(query, 9)).toEqual(11);
-    });
-  });
-
   describe('clearSearch()', function () {
     it('clears the query', function () {
       const props = {
@@ -776,87 +716,4 @@ describe('SmartSearchBar', function () {
       expect(searchBar.state.query).toEqual('user:"ip:127.0.0.1"');
     });
   });
-
-  describe('onTogglePinnedSearch()', function () {
-    let pinRequest, unpinRequest;
-    beforeEach(function () {
-      pinRequest = MockApiClient.addMockResponse({
-        url: '/organizations/org-slug/pinned-searches/',
-        method: 'PUT',
-        body: [],
-      });
-      unpinRequest = MockApiClient.addMockResponse({
-        url: '/organizations/org-slug/pinned-searches/',
-        method: 'DELETE',
-        body: [],
-      });
-      MockApiClient.addMockResponse({
-        url: '/organizations/org-slug/recent-searches/',
-        method: 'POST',
-        body: {},
-      });
-    });
-
-    it('does not pin when query is empty', async function () {
-      const wrapper = mountWithTheme(
-        <SmartSearchBar
-          api={new Client()}
-          organization={organization}
-          query=""
-          supportedTags={supportedTags}
-          savedSearchType={0}
-          location={location}
-          hasPinnedSearch
-        />,
-        options
-      );
-      wrapper.find('button[aria-label="Pin this search"]').simulate('click');
-      await wrapper.update();
-
-      expect(pinRequest).not.toHaveBeenCalled();
-    });
-
-    it('adds pins', async function () {
-      const wrapper = mountWithTheme(
-        <SmartSearchBar
-          api={new Client()}
-          organization={organization}
-          query="is:unresolved"
-          supportedTags={supportedTags}
-          savedSearchType={0}
-          location={location}
-          hasPinnedSearch
-        />,
-        options
-      );
-      wrapper.find('button[aria-label="Pin this search"]').simulate('click');
-      await wrapper.update();
-
-      expect(pinRequest).toHaveBeenCalled();
-      expect(unpinRequest).not.toHaveBeenCalled();
-    });
-
-    it('removes pins', async function () {
-      const pinnedSearch = TestStubs.Search({isPinned: true});
-      const wrapper = mountWithTheme(
-        <SmartSearchBar
-          api={new Client()}
-          organization={organization}
-          query="is:unresolved"
-          supportedTags={supportedTags}
-          savedSearchType={0}
-          location={location}
-          pinnedSearch={pinnedSearch}
-          hasPinnedSearch
-        />,
-        options
-      );
-
-      wrapper.find('button[aria-label="Unpin this search"]').simulate('click');
-      await wrapper.update();
-
-      expect(pinRequest).not.toHaveBeenCalled();
-      expect(unpinRequest).toHaveBeenCalled();
-    });
-  });
 });

+ 60 - 0
tests/js/spec/components/smartSearchBar/utils.spec.tsx

@@ -0,0 +1,60 @@
+import {
+  addSpace,
+  getLastTermIndex,
+  getQueryTerms,
+  removeSpace,
+} from 'app/components/smartSearchBar/utils';
+
+describe('addSpace()', function () {
+  it('should add a space when there is no trailing space', function () {
+    expect(addSpace('one')).toEqual('one ');
+  });
+
+  it('should not add another space when there is already one', function () {
+    expect(addSpace('one ')).toEqual('one ');
+  });
+
+  it('should leave the empty string alone', function () {
+    expect(addSpace('')).toEqual('');
+  });
+});
+
+describe('removeSpace()', function () {
+  it('should remove a trailing space', function () {
+    expect(removeSpace('one ')).toEqual('one');
+  });
+
+  it('should not remove the last character if it is not a space', function () {
+    expect(removeSpace('one')).toEqual('one');
+  });
+
+  it('should leave the empty string alone', function () {
+    expect(removeSpace('')).toEqual('');
+  });
+});
+
+describe('getQueryTerms()', function () {
+  it('should extract query terms from a query string', function () {
+    let query = 'tagname: ';
+    expect(getQueryTerms(query, query.length)).toEqual(['tagname:']);
+
+    query = 'tagname:derp browser:';
+    expect(getQueryTerms(query, query.length)).toEqual(['tagname:derp', 'browser:']);
+
+    query = '   browser:"Chrome 33.0"    ';
+    expect(getQueryTerms(query, query.length)).toEqual(['browser:"Chrome 33.0"']);
+  });
+});
+
+describe('getLastTermIndex()', function () {
+  it('should provide the index of the last query term, given cursor index', function () {
+    let query = 'tagname:';
+    expect(getLastTermIndex(query, 0)).toEqual(8);
+
+    query = 'tagname:foo'; // 'f' (index 9)
+    expect(getLastTermIndex(query, 9)).toEqual(11);
+
+    query = 'tagname:foo anothertag:bar'; // 'f' (index 9)
+    expect(getLastTermIndex(query, 9)).toEqual(11);
+  });
+});

+ 0 - 59
tests/js/spec/views/issueList/createSavedSearchButton.spec.jsx

@@ -1,59 +0,0 @@
-import {mountWithTheme} from 'sentry-test/enzyme';
-
-import {openModal} from 'app/actionCreators/modal';
-import CreateSavedSearchButton from 'app/views/issueList/createSavedSearchButton';
-
-jest.mock('app/actionCreators/modal');
-
-describe('CreateSavedSearchButton', function () {
-  let wrapper, organization;
-
-  beforeEach(function () {
-    organization = TestStubs.Organization({
-      access: ['org:write'],
-    });
-    wrapper = mountWithTheme(
-      <CreateSavedSearchButton
-        organization={organization}
-        query="is:unresolved assigned:lyn@sentry.io"
-      />,
-      TestStubs.routerContext()
-    );
-  });
-
-  afterEach(function () {
-    MockApiClient.clearMockResponses();
-  });
-
-  describe('saves a search', function () {
-    it('clicking save search opens modal', function () {
-      wrapper.find('button[data-test-id="save-current-search"]').simulate('click');
-
-      expect(openModal).toHaveBeenCalled();
-    });
-
-    it('shows button by default', function () {
-      const orgWithoutFeature = TestStubs.Organization({
-        access: ['org:write'],
-      });
-      wrapper.setProps({organization: orgWithoutFeature});
-
-      const button = wrapper.find(
-        'button[aria-label="Add to organization saved searches"]'
-      );
-      expect(button).toHaveLength(1);
-    });
-
-    it('hides button if no access', function () {
-      const orgWithoutAccess = TestStubs.Organization({
-        access: ['org:read'],
-      });
-      wrapper.setProps({organization: orgWithoutAccess});
-
-      const button = wrapper.find(
-        'button[aria-label="Add to organization saved searches"]'
-      );
-      expect(button).toHaveLength(0);
-    });
-  });
-});

+ 6 - 4
tests/js/spec/views/issueList/searchBar.spec.jsx

@@ -257,7 +257,8 @@ describe('IssueListSearchBar', function () {
         organization,
       };
       const searchBar = mountWithTheme(<IssueListSearchBar {...props} />, routerContext);
-      expect(searchBar.find('[data-test-id="pin-icon"]')).toHaveLength(2);
+
+      expect(searchBar.find('ActionButton[data-test-id="pin-icon"]')).toHaveLength(1);
     });
 
     it('pins a search from the searchbar', function () {
@@ -269,7 +270,7 @@ describe('IssueListSearchBar', function () {
         organization,
       };
       const searchBar = mountWithTheme(<IssueListSearchBar {...props} />, routerContext);
-      searchBar.find('button[aria-label="Pin this search"]').simulate('click');
+      searchBar.find('ActionButton[data-test-id="pin-icon"]').simulate('click');
 
       expect(pinSearch).toHaveBeenLastCalledWith(
         expect.anything(),
@@ -290,10 +291,11 @@ describe('IssueListSearchBar', function () {
         tagValueLoader: () => Promise.resolve([]),
         supportedTags,
         organization,
-        pinnedSearch: {id: '1', query: 'url:"fu"'},
+        savedSearch: {id: '1', isPinned: true, query: 'url:"fu"'},
       };
       const searchBar = mountWithTheme(<IssueListSearchBar {...props} />, routerContext);
-      searchBar.find('button[aria-label="Unpin this search"]').simulate('click');
+
+      searchBar.find('ActionButton[aria-label="Unpin this search"]').simulate('click');
 
       expect(unpinSearch).toHaveBeenLastCalledWith(
         expect.anything(),