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

feat(saved-searches): Add a saved searches sidebar and header button (#40744)

Malachi Willey 2 лет назад
Родитель
Сommit
e3f6cf112d

+ 14 - 1
static/app/views/issueList/header.tsx

@@ -10,7 +10,7 @@ import Link from 'sentry/components/links/link';
 import QueryCount from 'sentry/components/queryCount';
 import Tooltip from 'sentry/components/tooltip';
 import {SLOW_TOOLTIP_DELAY} from 'sentry/constants';
-import {IconPause, IconPlay} from 'sentry/icons';
+import {IconPause, IconPlay, IconStar} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {Organization, SavedSearch} from 'sentry/types';
@@ -23,7 +23,9 @@ import {getTabs, IssueSortOptions, Query, QueryCounts, TAB_MAX_COUNT} from './ut
 
 type Props = {
   displayReprocessingTab: boolean;
+  isSavedSearchesOpen: boolean;
   onRealtimeChange: (realtime: boolean) => void;
+  onToggleSavedSearches: (isOpen: boolean) => void;
   organization: Organization;
   query: string;
   queryCounts: QueryCounts;
@@ -46,7 +48,9 @@ function IssueListHeader({
   onSavedSearchSelect,
   onSavedSearchDelete,
   savedSearch,
+  onToggleSavedSearches,
   savedSearchList,
+  isSavedSearchesOpen,
   router,
   displayReprocessingTab,
   selectedProjectIds,
@@ -89,6 +93,15 @@ function IssueListHeader({
       <Layout.HeaderActions>
         <ButtonBar gap={1}>
           <IssueListSetAsDefault {...{sort, query, savedSearch, organization}} />
+          {organization.features.includes('issue-list-saved-searches-v2') && (
+            <Button
+              size="sm"
+              icon={<IconStar size="sm" isSolid={isSavedSearchesOpen} />}
+              onClick={() => onToggleSavedSearches(!isSavedSearchesOpen)}
+            >
+              {t('Saved Searches')}
+            </Button>
+          )}
           <Button
             size="sm"
             data-test-id="real-time"

+ 26 - 2
static/app/views/issueList/overview.tsx

@@ -57,6 +57,7 @@ import withIssueTags from 'sentry/utils/withIssueTags';
 import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 import withSavedSearches from 'sentry/utils/withSavedSearches';
+import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
 
 import IssueListActions from './actions';
 import IssueListFilters from './filters';
@@ -104,6 +105,7 @@ type State = {
   // TODO(Kelly): remove forReview once issue-list-removal-action feature is stable
   forReview: boolean;
   groupIds: string[];
+  isSavedSearchesOpen: boolean;
   issuesLoading: boolean;
   itemsRemoved: number;
   memberList: ReturnType<typeof indexMembersByProject>;
@@ -172,6 +174,7 @@ class IssueListOverview extends Component<Props, State> {
       queryCounts: {},
       queryMaxCount: 0,
       error: null,
+      isSavedSearchesOpen: false,
       issuesLoading: true,
       memberList: {},
     };
@@ -1097,6 +1100,12 @@ class IssueListOverview extends Component<Props, State> {
     this.fetchData(true);
   };
 
+  onToggleSavedSearches = (isOpen: boolean) => {
+    this.setState({
+      isSavedSearchesOpen: isOpen,
+    });
+  };
+
   tagValueLoader = (key: string, search: string) => {
     const {orgId} = this.props.params;
     const projectIds = this.getSelectedProjectIds();
@@ -1118,6 +1127,7 @@ class IssueListOverview extends Component<Props, State> {
     }
 
     const {
+      isSavedSearchesOpen,
       pageLinks,
       queryCount,
       queryCounts,
@@ -1128,8 +1138,15 @@ class IssueListOverview extends Component<Props, State> {
       issuesLoading,
       error,
     } = this.state;
-    const {organization, savedSearch, savedSearches, selection, location, router} =
-      this.props;
+    const {
+      organization,
+      savedSearch,
+      savedSearches,
+      savedSearchLoading,
+      selection,
+      location,
+      router,
+    } = this.props;
     const links = parseLinkHeader(pageLinks);
     const query = this.getQuery();
     const queryPageInt = parseInt(location.query.page, 10);
@@ -1172,6 +1189,8 @@ class IssueListOverview extends Component<Props, State> {
     return (
       <StyledPageContent>
         <IssueListHeader
+          isSavedSearchesOpen={isSavedSearchesOpen}
+          onToggleSavedSearches={this.onToggleSavedSearches}
           organization={organization}
           query={query}
           sort={this.getSort()}
@@ -1248,6 +1267,11 @@ class IssueListOverview extends Component<Props, State> {
               paginationAnalyticsEvent={this.paginationAnalyticsEvent}
             />
           </StyledMain>
+          <SavedIssueSearches
+            {...{savedSearches, savedSearch, savedSearchLoading, organization}}
+            isOpen={isSavedSearchesOpen}
+            onSavedSearchSelect={this.onSavedSearchSelect}
+          />
         </StyledBody>
       </StyledPageContent>
     );

+ 58 - 0
static/app/views/issueList/savedIssueSearches.spec.tsx

@@ -0,0 +1,58 @@
+import {ComponentProps} from 'react';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
+
+describe('SavedIssueSearches', function () {
+  const organization = TestStubs.Organization({
+    features: ['issue-list-saved-searches-v2'],
+  });
+
+  const recommendedSearch = TestStubs.Search({
+    isGlobal: true,
+    name: 'Assigned to Me',
+    query: 'is:unresolved assigned:me',
+  });
+
+  const orgSearch = TestStubs.Search({
+    isGlobal: false,
+    name: 'Last 4 Hours',
+    query: 'age:-4h',
+  });
+
+  const defaultProps: ComponentProps<typeof SavedIssueSearches> = {
+    isOpen: true,
+    savedSearches: [recommendedSearch, orgSearch],
+    savedSearch: null,
+    savedSearchLoading: false,
+    organization,
+    onSavedSearchSelect: jest.fn(),
+  };
+
+  beforeEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('displays saved searches with correct text and in correct sections', function () {
+    const {container} = render(<SavedIssueSearches {...defaultProps} />);
+
+    expect(container).toSnapshot();
+  });
+
+  it('can select a saved search', function () {
+    render(<SavedIssueSearches {...defaultProps} />);
+
+    userEvent.click(screen.getByRole('button', {name: 'Assigned to Me'}));
+    expect(defaultProps.onSavedSearchSelect).toHaveBeenLastCalledWith(recommendedSearch);
+
+    userEvent.click(screen.getByRole('button', {name: 'Last 4 Hours'}));
+    expect(defaultProps.onSavedSearchSelect).toHaveBeenLastCalledWith(orgSearch);
+  });
+
+  it('does not show header when there are no org saved searches', function () {
+    render(<SavedIssueSearches {...defaultProps} savedSearches={[recommendedSearch]} />);
+
+    expect(screen.queryByText(/saved searches/i)).not.toBeInTheDocument();
+  });
+});

+ 168 - 0
static/app/views/issueList/savedIssueSearches.tsx

@@ -0,0 +1,168 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Organization, SavedSearch} from 'sentry/types';
+
+interface SavedIssueSearchesProps {
+  isOpen: boolean;
+  onSavedSearchSelect: (savedSearch: SavedSearch) => void;
+  organization: Organization;
+  savedSearch: SavedSearch | null;
+  savedSearchLoading: boolean;
+  savedSearches: SavedSearch[];
+}
+
+interface SavedSearchItemProps {
+  onSavedSearchSelect: (savedSearch: SavedSearch) => void;
+  savedSearch: SavedSearch;
+}
+
+const SavedSearchItem = ({onSavedSearchSelect, savedSearch}: SavedSearchItemProps) => {
+  return (
+    <SearchListItem>
+      <StyledItemButton
+        aria-label={savedSearch.name}
+        onClick={() => onSavedSearchSelect(savedSearch)}
+      >
+        <div>
+          <SavedSearchItemTitle>{savedSearch.name}</SavedSearchItemTitle>
+          <SavedSearchItemDescription>{savedSearch.query}</SavedSearchItemDescription>
+        </div>
+      </StyledItemButton>
+    </SearchListItem>
+  );
+};
+
+const SavedIssueSearches = ({
+  organization,
+  isOpen,
+  onSavedSearchSelect,
+  savedSearchLoading,
+  savedSearches,
+}: SavedIssueSearchesProps) => {
+  if (!isOpen) {
+    return null;
+  }
+
+  if (!organization.features.includes('issue-list-saved-searches-v2')) {
+    return null;
+  }
+
+  if (savedSearchLoading) {
+    return (
+      <StyledSidebar>
+        <LoadingIndicator />
+      </StyledSidebar>
+    );
+  }
+
+  const orgSavedSearches = savedSearches.filter(
+    search => !search.isGlobal && !search.isPinned
+  );
+  const recommendedSavedSearches = savedSearches.filter(search => search.isGlobal);
+
+  return (
+    <StyledSidebar>
+      {orgSavedSearches.length > 0 && (
+        <Fragment>
+          <Heading>{t('Saved Searches')}</Heading>
+          <SearchesContainer>
+            {orgSavedSearches.map(item => (
+              <SavedSearchItem
+                key={item.id}
+                onSavedSearchSelect={onSavedSearchSelect}
+                savedSearch={item}
+              />
+            ))}
+          </SearchesContainer>
+        </Fragment>
+      )}
+      {recommendedSavedSearches.length > 0 && (
+        <Fragment>
+          <Heading>{t('Recommended')}</Heading>
+          <SearchesContainer>
+            {recommendedSavedSearches.map(item => (
+              <SavedSearchItem
+                key={item.id}
+                onSavedSearchSelect={onSavedSearchSelect}
+                savedSearch={item}
+              />
+            ))}
+          </SearchesContainer>
+        </Fragment>
+      )}
+    </StyledSidebar>
+  );
+};
+
+const StyledSidebar = styled('aside')`
+  width: 360px;
+  padding: ${space(4)} ${space(2)};
+
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    border-bottom: 1px solid ${p => p.theme.gray200};
+    padding: ${space(2)} 0;
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    border-left: 1px solid ${p => p.theme.gray200};
+  }
+`;
+
+const Heading = styled('h2')`
+  &:first-of-type {
+    margin-top: 0;
+  }
+
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  margin: ${space(3)} 0 ${space(2)} ${space(2)};
+`;
+
+const SearchesContainer = styled('ul')`
+  padding: 0;
+  margin-bottom: ${space(1)};
+`;
+
+const SearchListItem = styled('li')`
+  position: relative;
+  list-style: none;
+  padding: 0;
+  margin: 0;
+`;
+
+const StyledItemButton = styled('button')`
+  width: 100%;
+  background: ${p => p.theme.white};
+  border: 0;
+  border-radius: ${p => p.theme.borderRadius};
+  text-align: left;
+  display: block;
+
+  padding: ${space(1)} ${space(2)};
+  margin-top: 2px;
+
+  &:hover {
+    background: ${p => p.theme.hover};
+  }
+`;
+
+const SavedSearchItemTitle = styled('div')`
+  font-size: ${p => p.theme.fontSizeLarge};
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const SavedSearchItemDescription = styled('div')`
+  font-family: ${p => p.theme.text.familyMono};
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.subText};
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+export default SavedIssueSearches;