Browse Source

feat(workflow): Add team and name filters to incident history (#26253)

Scott Cooper 3 years ago
parent
commit
a90d226ada

+ 164 - 76
static/app/views/alerts/list/index.tsx

@@ -17,34 +17,34 @@ import LoadingIndicator from 'app/components/loadingIndicator';
 import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
 import Pagination from 'app/components/pagination';
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
+import SearchBar from 'app/components/searchBar';
 import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {IconCheckmark, IconInfo} from 'app/icons';
 import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
-import {Organization, Project} from 'app/types';
+import {Organization, Project, Team} from 'app/types';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import Projects from 'app/utils/projects';
 import withOrganization from 'app/utils/withOrganization';
+import withTeams from 'app/utils/withTeams';
 import EmptyMessage from 'app/views/settings/components/emptyMessage';
 
+import TeamFilter, {getTeamParams} from '../rules/teamFilter';
 import {Incident} from '../types';
 
 import AlertHeader from './header';
 import Onboarding from './onboarding';
 import AlertListRow from './row';
-import {TableLayout, TitleAndSparkLine} from './styles';
+import {TableLayout} from './styles';
 
 const DEFAULT_QUERY_STATUS = 'open';
 
 const DOCS_URL =
   'https://docs.sentry.io/workflow/alerts-notifications/alerts/?_ga=2.21848383.580096147.1592364314-1444595810.1582160976';
 
-function getQueryStatus(status: any): 'open' | 'closed' {
-  return ['open', 'closed'].includes(status) ? status : DEFAULT_QUERY_STATUS;
-}
-
 type Props = RouteComponentProps<{orgId: string}, {}> & {
   organization: Organization;
+  teams: Team[];
 };
 
 type State = {
@@ -65,22 +65,43 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
     const {params, location, organization} = this.props;
     const {query} = location;
-    const status = getQueryStatus(query.status);
-    const incidentsQuery = {
-      ...query,
-      ...(organization.features.includes('alert-details-redesign')
-        ? {expand: ['original_alert_rule']}
-        : {}),
-      status,
-    };
-
-    return [
-      [
-        'incidentList',
-        `/organizations/${params && params.orgId}/incidents/`,
-        {query: incidentsQuery},
-      ],
-    ];
+
+    const status = this.getQueryStatus(query.status);
+    // Filtering by one status, both does nothing
+    if (status.length === 1) {
+      query.status = status;
+    }
+
+    if (organization.features.includes('team-alerts-ownership')) {
+      query.team = getTeamParams(query.team);
+    }
+
+    if (organization.features.includes('alert-details-redesign')) {
+      query.expand = ['original_alert_rule'];
+    }
+
+    return [['incidentList', `/organizations/${params?.orgId}/incidents/`, {query}]];
+  }
+
+  getQueryStatus(status: string | string[]): string[] {
+    if (Array.isArray(status)) {
+      return status;
+    }
+
+    if (status === '') {
+      return [];
+    }
+
+    // No default status w/ alert-history-filters
+    const hasAlertHistoryFilters = this.props.organization.features.includes(
+      'alert-history-filters'
+    );
+
+    return ['open', 'closed'].includes(status as string)
+      ? [status as string]
+      : hasAlertHistoryFilters
+      ? []
+      : [DEFAULT_QUERY_STATUS];
   }
 
   /**
@@ -138,6 +159,66 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
     this.setState({hasAlertRule, firstVisitShown, loading: false});
   }
 
+  handleChangeSearch = (title: string) => {
+    const {router, location} = this.props;
+    const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
+    router.push({
+      pathname: location.pathname,
+      query: {
+        ...currentQuery,
+        title,
+      },
+    });
+  };
+
+  handleChangeFilter = (sectionId: string, activeFilters: Set<string>) => {
+    const {router, location} = this.props;
+    const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
+
+    let team = currentQuery.team;
+    if (sectionId === 'teams') {
+      team = activeFilters.size ? [...activeFilters] : '';
+    }
+
+    let status = currentQuery.status;
+    if (sectionId === 'status') {
+      status = activeFilters.size ? [...activeFilters] : '';
+    }
+
+    router.push({
+      pathname: location.pathname,
+      query: {
+        ...currentQuery,
+        status,
+        // Preserve empty team query parameter
+        team: team.length === 0 ? '' : team,
+      },
+    });
+  };
+
+  renderFilterBar() {
+    const {teams, location} = this.props;
+    const selectedTeams = new Set(getTeamParams(location.query.team));
+    const selectedStatus = new Set(this.getQueryStatus(location.query.status));
+
+    return (
+      <FilterWrapper>
+        <TeamFilter
+          showStatus
+          teams={teams}
+          selectedStatus={selectedStatus}
+          selectedTeams={selectedTeams}
+          handleChangeFilter={this.handleChangeFilter}
+        />
+        <StyledSearchBar
+          placeholder={t('Search by name')}
+          query={location.query?.name}
+          onSearch={this.handleChangeSearch}
+        />
+      </FilterWrapper>
+    );
+  }
+
   tryRenderOnboarding() {
     const {firstVisitShown} = this.state;
     const {organization} = this.props;
@@ -167,8 +248,7 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
   }
 
   tryRenderEmpty() {
-    const {hasAlertRule, incidentList} = this.state;
-    const status = getQueryStatus(this.props.location.query.status);
+    const {incidentList} = this.state;
 
     if (!incidentList || incidentList.length > 0) {
       return null;
@@ -178,13 +258,7 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
       <EmptyMessage
         size="medium"
         icon={<IconCheckmark isCircled size="48" />}
-        title={
-          !hasAlertRule
-            ? t('No metric alert rules exist for the selected projects.')
-            : status === 'open'
-            ? t('No unresolved metric alerts in the selected projects.')
-            : t('No resolved metric alerts in the selected projects.')
-        }
+        title={t('No incidents exist for the current query.')}
         description={tct('Learn more about [link:Metric Alerts]', {
           link: <ExternalLink href={DOCS_URL} />,
         })}
@@ -211,25 +285,27 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
         ? true
         : false;
     const showLoadingIndicator = loading || checkingForAlertRules;
-    const status = getQueryStatus(this.props.location.query.status);
 
     return (
       <Fragment>
         {this.tryRenderOnboarding() ?? (
           <Panel>
             {!loading && (
-              <StyledPanelHeader>
-                <TableLayout status={status}>
-                  <PaddedTitleAndSparkLine status={status}>
-                    <div>{t('Alert')}</div>
-                    {status === 'open' && <div>{t('Graph')}</div>}
-                  </PaddedTitleAndSparkLine>
+              <PanelHeader>
+                <TableLayout>
+                  <div>{t('Alert')}</div>
+                  <div>{t('Alert Rule')}</div>
                   <div>{t('Project')}</div>
-                  <div>{t('Triggered')}</div>
-                  {status === 'closed' && <div>{t('Duration')}</div>}
-                  {status === 'closed' && <div>{t('Resolved')}</div>}
+                  <div>
+                    <Feature
+                      features={['team-alerts-ownership']}
+                      organization={organization}
+                    >
+                      {t('Team')}
+                    </Feature>
+                  </div>
                 </TableLayout>
-              </StyledPanelHeader>
+              </PanelHeader>
             )}
             {showLoadingIndicator ? (
               <LoadingIndicator />
@@ -245,7 +321,6 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
                           projects={projects as Project[]}
                           incident={incident}
                           orgId={orgId}
-                          filteredStatus={status}
                           organization={organization}
                         />
                       ))
@@ -262,20 +337,22 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
   }
 
   renderBody() {
-    const {params, location, organization, router} = this.props;
+    const {params, organization, router, location} = this.props;
     const {pathname, query} = location;
     const {orgId} = params;
 
     const openIncidentsQuery = omit({...query, status: 'open'}, 'cursor');
     const closedIncidentsQuery = omit({...query, status: 'closed'}, 'cursor');
-
-    const status = getQueryStatus(query.status);
+    const status = this.getQueryStatus(location.query.status)[0] || DEFAULT_QUERY_STATUS;
+    const hasAlertHistoryFilters = organization.features.includes(
+      'alert-history-filters'
+    );
 
     return (
       <SentryDocumentTitle title={t('Alerts')} orgSlug={orgId}>
         <GlobalSelectionHeader organization={organization} showDateSelector={false}>
           <AlertHeader organization={organization} router={router} activeTab="stream" />
-          <Layout.Body>
+          <StyledLayoutBody>
             <Layout.Main fullWidth>
               {!this.tryRenderOnboarding() && (
                 <Fragment>
@@ -283,31 +360,35 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
                     features={['alert-details-redesign']}
                     organization={organization}
                   >
-                    <Alert icon={<IconInfo />}>
-                      {t('This page only shows metric alerts that have been triggered.')}
-                    </Alert>
+                    <StyledAlert icon={<IconInfo />}>
+                      {t('This page only shows metric alerts.')}
+                    </StyledAlert>
                   </Feature>
-                  <StyledButtonBar merged active={status}>
-                    <Button
-                      to={{pathname, query: openIncidentsQuery}}
-                      barId="open"
-                      size="small"
-                    >
-                      {t('Unresolved')}
-                    </Button>
-                    <Button
-                      to={{pathname, query: closedIncidentsQuery}}
-                      barId="closed"
-                      size="small"
-                    >
-                      {t('Resolved')}
-                    </Button>
-                  </StyledButtonBar>
+                  {hasAlertHistoryFilters ? (
+                    this.renderFilterBar()
+                  ) : (
+                    <StyledButtonBar merged active={status}>
+                      <Button
+                        to={{pathname, query: openIncidentsQuery}}
+                        barId="open"
+                        size="small"
+                      >
+                        {t('Unresolved')}
+                      </Button>
+                      <Button
+                        to={{pathname, query: closedIncidentsQuery}}
+                        barId="closed"
+                        size="small"
+                      >
+                        {t('Resolved')}
+                      </Button>
+                    </StyledButtonBar>
+                  )}
                 </Fragment>
               )}
               {this.renderList()}
             </Layout.Main>
-          </Layout.Body>
+          </StyledLayoutBody>
         </GlobalSelectionHeader>
       </SentryDocumentTitle>
     );
@@ -326,14 +407,12 @@ class IncidentsListContainer extends Component<Props> {
   }
 
   trackView() {
-    const {location, organization} = this.props;
-    const status = getQueryStatus(location.query.status);
+    const {organization} = this.props;
 
     trackAnalyticsEvent({
       eventKey: 'alert_stream.viewed',
       eventName: 'Alert Stream: Viewed',
       organization_id: organization.id,
-      status,
     });
   }
 
@@ -368,13 +447,22 @@ const StyledButtonBar = styled(ButtonBar)`
   margin-bottom: ${space(1)};
 `;
 
-const PaddedTitleAndSparkLine = styled(TitleAndSparkLine)`
-  padding-left: ${space(2)};
+const StyledAlert = styled(Alert)`
+  margin-bottom: ${space(1.5)};
+`;
+
+const FilterWrapper = styled('div')`
+  display: flex;
+  margin-bottom: ${space(1.5)};
+`;
+
+const StyledSearchBar = styled(SearchBar)`
+  flex-grow: 1;
+  margin-left: ${space(1.5)};
 `;
 
-const StyledPanelHeader = styled(PanelHeader)`
-  /* Match table row padding for the grid to align */
-  padding: ${space(1.5)} ${space(2)} ${space(1.5)} 0;
+const StyledLayoutBody = styled(Layout.Body)`
+  margin-bottom: -20px;
 `;
 
-export default withOrganization(IncidentsListContainer);
+export default withOrganization(withTeams(IncidentsListContainer));

+ 45 - 122
static/app/views/alerts/list/row.tsx

@@ -1,34 +1,30 @@
+import {Component} from 'react';
 import styled from '@emotion/styled';
 import memoize from 'lodash/memoize';
 import moment from 'moment';
 
-import AsyncComponent from 'app/components/asyncComponent';
+import ActorAvatar from 'app/components/avatar/actorAvatar';
 import Duration from 'app/components/duration';
 import ErrorBoundary from 'app/components/errorBoundary';
 import IdBadge from 'app/components/idBadge';
 import Link from 'app/components/links/link';
 import {PanelItem} from 'app/components/panels';
 import TimeSince from 'app/components/timeSince';
-import Tooltip from 'app/components/tooltip';
-import {IconWarning} from 'app/icons';
 import {t, tct} from 'app/locale';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
-import space from 'app/styles/space';
-import {Organization, Project} from 'app/types';
+import {Actor, Organization, Project} from 'app/types';
 import {getUtcDateString} from 'app/utils/dates';
 import getDynamicText from 'app/utils/getDynamicText';
-import theme from 'app/utils/theme';
 import {alertDetailsLink} from 'app/views/alerts/details';
 
 import {
   API_INTERVAL_POINTS_LIMIT,
   API_INTERVAL_POINTS_MIN,
 } from '../rules/details/constants';
-import {Incident, IncidentStats, IncidentStatus} from '../types';
+import {Incident, IncidentStatus} from '../types';
 import {getIncidentMetricPreset, isIssueAlert} from '../utils';
 
-import SparkLine from './sparkLine';
-import {TableLayout, TitleAndSparkLine} from './styles';
+import {TableLayout} from './styles';
 
 /**
  * Retrieve the start/end for showing the graph of the metric
@@ -60,32 +56,15 @@ type Props = {
   projects: Project[];
   projectsLoaded: boolean;
   orgId: string;
-  filteredStatus: 'open' | 'closed';
   organization: Organization;
-} & AsyncComponent['props'];
-
-type State = {
-  stats: IncidentStats;
-} & AsyncComponent['state'];
+};
 
-class AlertListRow extends AsyncComponent<Props, State> {
+class AlertListRow extends Component<Props> {
   get metricPreset() {
     const {incident} = this.props;
     return incident ? getIncidentMetricPreset(incident) : undefined;
   }
 
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {orgId, incident, filteredStatus} = this.props;
-
-    if (filteredStatus === 'open') {
-      return [
-        ['stats', `/organizations/${orgId}/incidents/${incident.identifier}/stats/`],
-      ];
-    }
-
-    return [];
-  }
-
   /**
    * Memoized function to find a project from a list of projects
    */
@@ -93,52 +72,13 @@ class AlertListRow extends AsyncComponent<Props, State> {
     projects.find(project => project.slug === slug)
   );
 
-  renderLoading() {
-    return this.renderBody();
-  }
-
-  renderError() {
-    return this.renderBody();
-  }
-
-  renderTimeSince(date: string) {
-    return (
-      <CreatedResolvedTime>
-        <TimeSince date={date} />
-      </CreatedResolvedTime>
-    );
-  }
-
-  renderStatusIndicator() {
-    const {status} = this.props.incident;
-    const isResolved = status === IncidentStatus.CLOSED;
-    const isWarning = status === IncidentStatus.WARNING;
-
-    const color = isResolved ? theme.gray200 : isWarning ? theme.orange300 : theme.red200;
-    const text = isResolved ? t('Resolved') : isWarning ? t('Warning') : t('Critical');
-
-    return (
-      <Tooltip title={tct('Status: [text]', {text})}>
-        <StatusIndicator color={color} />
-      </Tooltip>
-    );
-  }
-
-  renderBody() {
-    const {
-      incident,
-      orgId,
-      projectsLoaded,
-      projects,
-      filteredStatus,
-      organization,
-    } = this.props;
-    const {error, stats} = this.state;
+  render() {
+    const {incident, orgId, projectsLoaded, projects, organization} = this.props;
+    const slug = incident.projects[0];
     const started = moment(incident.dateStarted);
     const duration = moment
       .duration(moment(incident.dateClosed || new Date()).diff(started))
       .as('seconds');
-    const slug = incident.projects[0];
 
     const hasRedesign =
       !isIssueAlert(incident.alertRule) &&
@@ -152,40 +92,44 @@ class AlertListRow extends AsyncComponent<Props, State> {
       : {
           pathname: `/organizations/${orgId}/alerts/${incident.identifier}/`,
         };
+    const hasAlertOwnership = organization.features.includes('team-alerts-ownership');
+    const ownerId = incident.alertRule.owner?.split(':')[1];
+    const teamActor = ownerId
+      ? {type: 'team' as Actor['type'], id: ownerId, name: ''}
+      : null;
 
     return (
       <ErrorBoundary>
         <IncidentPanelItem>
-          <TableLayout status={filteredStatus}>
-            <TitleAndSparkLine status={filteredStatus}>
-              <Title>
-                {this.renderStatusIndicator()}
-                <IncidentLink to={alertLink}>Alert #{incident.id}</IncidentLink>
-                {incident.title}
-              </Title>
-
-              {filteredStatus === 'open' && (
-                <SparkLine
-                  error={error && <ErrorLoadingStatsIcon />}
-                  eventStats={stats?.eventStats}
-                />
-              )}
-            </TitleAndSparkLine>
+          <TableLayout>
+            <Title>
+              <Link to={alertLink}>Alert #{incident.id}</Link>
+              <div>
+                {t('Triggered ')} <TimeSince date={incident.dateStarted} extraShort />
+                <StyledTimeSeparator> | </StyledTimeSeparator>
+                {incident.status === IncidentStatus.CLOSED
+                  ? tct('Active for [duration]', {
+                      duration: (
+                        <Duration
+                          seconds={getDynamicText({value: duration, fixed: 1200})}
+                        />
+                      ),
+                    })
+                  : t('Still Active')}
+              </div>
+            </Title>
+
+            <div>{incident.title}</div>
 
             <ProjectBadge
               avatarSize={18}
               project={!projectsLoaded ? {slug} : this.getProject(slug, projects)}
             />
 
-            {this.renderTimeSince(incident.dateStarted)}
-
-            {filteredStatus === 'closed' && (
-              <Duration seconds={getDynamicText({value: duration, fixed: 1200})} />
-            )}
-
-            {filteredStatus === 'closed' &&
-              incident.dateClosed &&
-              this.renderTimeSince(incident.dateClosed)}
+            <FlexCenter>
+              {hasAlertOwnership &&
+                (teamActor ? <ActorAvatar actor={teamActor} size={24} /> : '-')}
+            </FlexCenter>
           </TableLayout>
         </IncidentPanelItem>
       </ErrorBoundary>
@@ -193,46 +137,25 @@ class AlertListRow extends AsyncComponent<Props, State> {
   }
 }
 
-function ErrorLoadingStatsIcon() {
-  return (
-    <Tooltip title={t('Error loading alert stats')}>
-      <IconWarning />
-    </Tooltip>
-  );
-}
-
-const CreatedResolvedTime = styled('div')`
-  ${overflowEllipsis}
-  line-height: 1.4;
-  display: flex;
-  align-items: center;
-`;
-
 const ProjectBadge = styled(IdBadge)`
   flex-shrink: 0;
 `;
 
-const StatusIndicator = styled('div')<{color: string}>`
-  width: 10px;
-  height: 12px;
-  background: ${p => p.color};
-  display: inline-block;
-  border-top-right-radius: 40%;
-  border-bottom-right-radius: 40%;
-  margin-bottom: -1px;
+const StyledTimeSeparator = styled('span')`
+  color: ${p => p.theme.gray200};
 `;
 
 const Title = styled('span')`
   ${overflowEllipsis}
 `;
 
-const IncidentLink = styled(Link)`
-  padding: 0 ${space(1)};
-`;
-
 const IncidentPanelItem = styled(PanelItem)`
   font-size: ${p => p.theme.fontSizeMedium};
-  padding: ${space(1.5)} ${space(2)} ${space(1.5)} 0;
+`;
+
+const FlexCenter = styled('div')`
+  display: flex;
+  align-items: center;
 `;
 
 export default AlertListRow;

+ 0 - 65
static/app/views/alerts/list/sparkLine.tsx

@@ -1,65 +0,0 @@
-import * as React from 'react';
-import styled from '@emotion/styled';
-
-import Placeholder from 'app/components/placeholder';
-import theme from 'app/utils/theme';
-import {IncidentStats} from 'app/views/alerts/types';
-
-// Height of sparkline
-const SPARKLINE_HEIGHT = 38;
-
-type Props = {
-  className?: string;
-  eventStats: IncidentStats['eventStats'];
-  error?: React.ReactNode;
-};
-
-const Sparklines = React.lazy(() => import('app/components/sparklines'));
-const SparklinesLine = React.lazy(() => import('app/components/sparklines/line'));
-
-class SparkLine extends React.Component<Props> {
-  render() {
-    const {className, error, eventStats} = this.props;
-
-    if (error) {
-      return <SparklineError error={error} />;
-    }
-
-    if (!eventStats) {
-      return <SparkLinePlaceholder />;
-    }
-
-    const data = eventStats.data.map(([, value]) =>
-      value && Array.isArray(value) && value.length ? value[0].count || 0 : 0
-    );
-
-    return (
-      <React.Suspense fallback={<SparkLinePlaceholder />}>
-        <div data-test-id="incident-sparkline" className={className}>
-          <Sparklines data={data} width={100} height={32}>
-            <SparklinesLine
-              style={{stroke: theme.gray300, fill: 'none', strokeWidth: 2}}
-            />
-          </Sparklines>
-        </div>
-      </React.Suspense>
-    );
-  }
-}
-
-const StyledSparkLine = styled(SparkLine)`
-  flex-shrink: 0;
-  width: 100%;
-  height: ${SPARKLINE_HEIGHT}px;
-`;
-
-const SparkLinePlaceholder = styled(Placeholder)`
-  height: ${SPARKLINE_HEIGHT}px;
-`;
-
-const SparklineError = styled(SparkLinePlaceholder)`
-  align-items: center;
-  line-height: 1;
-`;
-
-export default StyledSparkLine;

+ 3 - 13
static/app/views/alerts/list/styles.tsx

@@ -2,22 +2,12 @@ import styled from '@emotion/styled';
 
 import space from 'app/styles/space';
 
-const TableLayout = styled('div')<{status: 'open' | 'closed'}>`
+const TableLayout = styled('div')`
   display: grid;
-  grid-template-columns: ${p =>
-    p.status === 'open' ? '4fr 1fr 2fr' : '3fr 2fr 2fr 1fr 2fr'};
+  grid-template-columns: 3fr 2fr 2fr 0.5fr;
   grid-column-gap: ${space(1.5)};
   width: 100%;
   align-items: center;
 `;
 
-const TitleAndSparkLine = styled('div')<{status: 'open' | 'closed'}>`
-  display: ${p => (p.status === 'open' ? 'grid' : 'flex')};
-  grid-gap: ${space(1)};
-  grid-template-columns: auto 120px;
-  align-items: center;
-  padding-right: ${space(2)};
-  overflow: hidden;
-`;
-
-export {TableLayout, TitleAndSparkLine};
+export {TableLayout};

+ 126 - 69
static/app/views/alerts/rules/filter.tsx

@@ -1,4 +1,4 @@
-import * as React from 'react';
+import {Component, Fragment} from 'react';
 import styled from '@emotion/styled';
 
 import CheckboxFancy from 'app/components/checkboxFancy/checkboxFancy';
@@ -6,6 +6,7 @@ import DropdownButton from 'app/components/dropdownButton';
 import DropdownControl, {Content} from 'app/components/dropdownControl';
 import {IconFilter} from 'app/icons';
 import {t, tn} from 'app/locale';
+import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
 
 type DropdownButtonProps = React.ComponentProps<typeof DropdownButton>;
@@ -14,66 +15,115 @@ export type RenderProps = {
   toggleFilter: (filter: string) => void;
 };
 
-type RenderFunc = (props: RenderProps) => React.ReactElement;
+type DropdownSection = {
+  id: string;
+  label: string;
+  items: Array<{label: string; value: string; checked: boolean; filtered: boolean}>;
+};
+
+type SectionProps = DropdownSection & {
+  toggleSection: (id: string) => void;
+  toggleFilter: (section: string, value: string) => void;
+};
+
+function FilterSection({id, label, items, toggleSection, toggleFilter}: SectionProps) {
+  const checkedItemsCount = items.filter(item => item.checked).length;
+  return (
+    <Fragment>
+      <Header>
+        <span>{label}</span>
+        <CheckboxFancy
+          isChecked={checkedItemsCount === items.length}
+          isIndeterminate={checkedItemsCount > 0 && checkedItemsCount !== items.length}
+          onClick={event => {
+            event.stopPropagation();
+            toggleSection(id);
+          }}
+        />
+      </Header>
+      {items.map(item => (
+        <ListItem
+          key={item.value}
+          isChecked={item.checked}
+          onClick={event => {
+            event.stopPropagation();
+            toggleFilter(id, item.value);
+          }}
+        >
+          <TeamName>{item.label}</TeamName>
+          <CheckboxFancy isChecked={item.checked} />
+        </ListItem>
+      ))}
+    </Fragment>
+  );
+}
 
 type Props = {
   header: React.ReactElement;
-  headerLabel: string;
-  onFilterChange: (filterSelection: Set<string>) => void;
-  filterList: string[];
-  children: RenderFunc;
-  selection: Set<string>;
+  onFilterChange: (section: string, filterSelection: Set<string>) => void;
+  dropdownSections: DropdownSection[];
 };
 
-class Filter extends React.Component<Props> {
-  toggleFilter = (filter: string) => {
-    const {onFilterChange, selection} = this.props;
-    const newSelection = new Set(selection);
-    if (newSelection.has(filter)) {
-      newSelection.delete(filter);
+class Filter extends Component<Props> {
+  toggleFilter = (sectionId: string, value: string) => {
+    const {onFilterChange, dropdownSections} = this.props;
+    const section = dropdownSections.find(
+      dropdownSection => dropdownSection.id === sectionId
+    )!;
+    const newSelection = new Set(
+      section.items.filter(item => item.checked).map(item => item.value)
+    );
+    if (newSelection.has(value)) {
+      newSelection.delete(value);
     } else {
-      newSelection.add(filter);
+      newSelection.add(value);
     }
-    onFilterChange(newSelection);
+    onFilterChange(sectionId, newSelection);
   };
 
-  toggleAllFilters = () => {
-    const {filterList, onFilterChange, selection} = this.props;
+  toggleSection = (sectionId: string) => {
+    const {onFilterChange} = this.props;
+    const section = this.props.dropdownSections.find(
+      dropdownSection => dropdownSection.id === sectionId
+    )!;
+    const activeItems = section.items.filter(item => item.checked);
+
     const newSelection =
-      selection.size === filterList.length ? new Set<string>() : new Set(filterList);
+      section.items.length === activeItems.length
+        ? new Set<string>()
+        : new Set(section.items.map(item => item.value));
 
-    onFilterChange(newSelection);
+    onFilterChange(sectionId, newSelection);
   };
 
   getNumberOfActiveFilters = (): number => {
-    const {selection} = this.props;
-    return selection.size;
+    return this.props.dropdownSections
+      .map(section => section.items)
+      .flat()
+      .filter(item => item.checked).length;
   };
 
   render() {
-    const {children, header, headerLabel, filterList} = this.props;
+    const {dropdownSections: dropdownItems, header} = this.props;
     const checkedQuantity = this.getNumberOfActiveFilters();
 
     const dropDownButtonProps: Pick<DropdownButtonProps, 'children' | 'priority'> & {
       hasDarkBorderBottomColor: boolean;
     } = {
-      children: (
-        <React.Fragment>
-          <IconFilter size="xs" />
-          <FilterLabel>{t('Filter')}</FilterLabel>
-        </React.Fragment>
-      ),
+      children: t('Filter'),
       priority: 'default',
       hasDarkBorderBottomColor: false,
     };
 
     if (checkedQuantity > 0) {
-      dropDownButtonProps.children = (
-        <span>{tn('%s Active Filter', '%s Active Filters', checkedQuantity)}</span>
+      dropDownButtonProps.children = tn(
+        '%s Active Filter',
+        '%s Active Filters',
+        checkedQuantity
       );
-      dropDownButtonProps.priority = 'primary';
       dropDownButtonProps.hasDarkBorderBottomColor = true;
     }
+
     return (
       <DropdownControl
         menuWidth="240px"
@@ -84,6 +134,7 @@ class Filter extends React.Component<Props> {
             {...getActorProps()}
             showChevron={false}
             isOpen={isOpen}
+            icon={<IconFilter size="xs" />}
             hasDarkBorderBottomColor={dropDownButtonProps.hasDarkBorderBottomColor}
             priority={dropDownButtonProps.priority as DropdownButtonProps['priority']}
             data-test-id="filter-button"
@@ -100,25 +151,17 @@ class Filter extends React.Component<Props> {
             alignMenu="left"
             width="240px"
           >
-            {isOpen && (
-              <React.Fragment>
-                {header}
-                <Header>
-                  <span>{headerLabel}</span>
-                  <CheckboxFancy
-                    isChecked={checkedQuantity > 0}
-                    isIndeterminate={
-                      checkedQuantity > 0 && checkedQuantity !== filterList.length
-                    }
-                    onClick={event => {
-                      event.stopPropagation();
-                      this.toggleAllFilters();
-                    }}
-                  />
-                </Header>
-                {children({toggleFilter: this.toggleFilter})}
-              </React.Fragment>
-            )}
+            <List>
+              {header}
+              {dropdownItems.map(section => (
+                <FilterSection
+                  key={section.id}
+                  {...section}
+                  toggleSection={this.toggleSection}
+                  toggleFilter={this.toggleFilter}
+                />
+              ))}
+            </List>
           </MenuContent>
         )}
       </DropdownControl>
@@ -127,7 +170,7 @@ class Filter extends React.Component<Props> {
 }
 
 const MenuContent = styled(Content)`
-  max-height: 250px;
+  max-height: 290px;
   overflow-y: auto;
 `;
 
@@ -146,32 +189,46 @@ const Header = styled('div')`
   border-bottom: 1px solid ${p => p.theme.border};
 `;
 
-const FilterLabel = styled('span')`
-  margin-left: ${space(1)};
-`;
-
 const StyledDropdownButton = styled(DropdownButton)<{hasDarkBorderBottomColor?: boolean}>`
   white-space: nowrap;
   max-width: 200px;
 
   z-index: ${p => p.theme.zIndex.dropdown};
+`;
+
+const List = styled('ul')`
+  list-style: none;
+  margin: 0;
+  padding: 0;
+`;
 
-  &:hover,
-  &:active {
-    ${p =>
-      !p.isOpen &&
-      p.hasDarkBorderBottomColor &&
-      `
-          border-bottom-color: ${p.theme.button.primary.border};
-        `}
+const ListItem = styled('li')<{isChecked?: boolean}>`
+  display: grid;
+  grid-template-columns: 1fr max-content;
+  grid-column-gap: ${space(1)};
+  align-items: center;
+  padding: ${space(1)} ${space(2)};
+  border-bottom: 1px solid ${p => p.theme.border};
+  :hover {
+    background-color: ${p => p.theme.backgroundSecondary};
+  }
+  ${CheckboxFancy} {
+    opacity: ${p => (p.isChecked ? 1 : 0.3)};
   }
 
-  ${p =>
-    !p.isOpen &&
-    p.hasDarkBorderBottomColor &&
-    `
-      border-bottom-color: ${p.theme.button.primary.border};
-    `}
+  &:hover ${CheckboxFancy} {
+    opacity: 1;
+  }
+
+  &:hover span {
+    color: ${p => p.theme.blue300};
+    text-decoration: underline;
+  }
+`;
+
+const TeamName = styled('div')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  ${overflowEllipsis};
 `;
 
 export default Filter;

+ 10 - 132
static/app/views/alerts/rules/index.tsx

@@ -5,8 +5,6 @@ import flatten from 'lodash/flatten';
 
 import {addErrorMessage} from 'app/actionCreators/indicator';
 import AsyncComponent from 'app/components/asyncComponent';
-import CheckboxFancy from 'app/components/checkboxFancy/checkboxFancy';
-import Input from 'app/components/forms/input';
 import * as Layout from 'app/components/layouts/thirds';
 import ExternalLink from 'app/components/links/externalLink';
 import Link from 'app/components/links/link';
@@ -17,7 +15,6 @@ import SearchBar from 'app/components/searchBar';
 import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {IconArrow, IconCheckmark} from 'app/icons';
 import {t, tct} from 'app/locale';
-import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
 import {GlobalSelection, Organization, Project, Team} from 'app/types';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
@@ -29,11 +26,10 @@ import AlertHeader from '../list/header';
 import {CombinedMetricIssueAlerts} from '../types';
 import {isIssueAlert} from '../utils';
 
-import Filter from './filter';
 import RuleListRow from './row';
+import TeamFilter, {getTeamParams} from './teamFilter';
 
 const DOCS_URL = 'https://docs.sentry.io/product/alerts-notifications/metric-alerts/';
-const ALERT_LIST_QUERY_DEFAULT_TEAMS = ['myteams', 'unassigned'];
 
 type Props = RouteComponentProps<{orgId: string}, {}> & {
   organization: Organization;
@@ -56,7 +52,7 @@ class AlertRulesList extends AsyncComponent<Props, State & AsyncComponent['state
     }
 
     if (organization.features.includes('team-alerts-ownership')) {
-      query.team = this.getTeamQuery();
+      query.team = getTeamParams(query.team);
     }
 
     if (organization.features.includes('alert-details-redesign') && !query.sort) {
@@ -74,25 +70,6 @@ class AlertRulesList extends AsyncComponent<Props, State & AsyncComponent['state
     ];
   }
 
-  getTeamQuery(): string[] {
-    const {
-      location: {query},
-    } = this.props;
-    if (query.team === undefined) {
-      return ALERT_LIST_QUERY_DEFAULT_TEAMS;
-    }
-
-    if (query.team === '') {
-      return [];
-    }
-
-    if (Array.isArray(query.team)) {
-      return query.team;
-    }
-
-    return [query.team];
-  }
-
   tryRenderEmpty() {
     const {ruleList} = this.state;
     if (ruleList && ruleList.length > 0) {
@@ -115,7 +92,7 @@ class AlertRulesList extends AsyncComponent<Props, State & AsyncComponent['state
     );
   }
 
-  handleChangeFilter = (activeFilters: Set<string>) => {
+  handleChangeFilter = (_sectionId: string, activeFilters: Set<string>) => {
     const {router, location} = this.props;
     const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
     const teams = [...activeFilters];
@@ -164,73 +141,15 @@ class AlertRulesList extends AsyncComponent<Props, State & AsyncComponent['state
 
   renderFilterBar() {
     const {teams, location} = this.props;
-    const {teamFilterSearch} = this.state;
-    const selectedTeams = new Set(this.getTeamQuery());
-    const additionalOptions = [
-      {label: t('My Teams'), value: 'myteams'},
-      {label: t('Unassigned'), value: 'unassigned'},
-    ];
-    const optionValues = [
-      ...teams.map(({id}) => id),
-      ...additionalOptions.map(({value}) => value),
-    ];
-    const filteredTeams = teams.filter(({name}) =>
-      teamFilterSearch
-        ? name.toLowerCase().includes(teamFilterSearch.toLowerCase())
-        : true
-    );
+    const selectedTeams = new Set(getTeamParams(location.query.team));
+
     return (
       <FilterWrapper>
-        <Filter
-          header={
-            <StyledInput
-              autoFocus
-              placeholder={t('Filter by team name')}
-              onClick={event => {
-                event.stopPropagation();
-              }}
-              onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
-                this.setState({teamFilterSearch: event.target.value});
-              }}
-              value={this.state.teamFilterSearch || ''}
-            />
-          }
-          headerLabel={t('Team')}
-          onFilterChange={this.handleChangeFilter}
-          filterList={optionValues}
-          selection={selectedTeams}
-        >
-          {({toggleFilter}) => (
-            <List>
-              {additionalOptions.map(({label, value}) => (
-                <ListItem
-                  key={value}
-                  isChecked={selectedTeams.has(value)}
-                  onClick={event => {
-                    event.stopPropagation();
-                    toggleFilter(value);
-                  }}
-                >
-                  <TeamName>{label}</TeamName>
-                  <CheckboxFancy isChecked={selectedTeams.has(value)} />
-                </ListItem>
-              ))}
-              {filteredTeams.map(({id, name}) => (
-                <ListItem
-                  key={id}
-                  isChecked={selectedTeams.has(id)}
-                  onClick={event => {
-                    event.stopPropagation();
-                    toggleFilter(id);
-                  }}
-                >
-                  <TeamName>{name}</TeamName>
-                  <CheckboxFancy isChecked={selectedTeams.has(id)} />
-                </ListItem>
-              ))}
-            </List>
-          )}
-        </Filter>
+        <TeamFilter
+          teams={teams}
+          selectedTeams={selectedTeams}
+          handleChangeFilter={this.handleChangeFilter}
+        />
         <StyledSearchBar
           placeholder={t('Search by name')}
           query={location.query?.name}
@@ -433,11 +352,6 @@ const StyledSortLink = styled(Link)`
   }
 `;
 
-const TeamName = styled('div')`
-  font-size: ${p => p.theme.fontSizeMedium};
-  ${overflowEllipsis};
-`;
-
 const FilterWrapper = styled('div')`
   display: flex;
   margin-bottom: ${space(1.5)};
@@ -448,42 +362,6 @@ const StyledSearchBar = styled(SearchBar)`
   margin-left: ${space(1.5)};
 `;
 
-const List = styled('ul')`
-  list-style: none;
-  margin: 0;
-  padding: 0;
-`;
-
-const StyledInput = styled(Input)`
-  border: none;
-  border-bottom: 1px solid ${p => p.theme.gray200};
-  border-radius: 0;
-`;
-
-const ListItem = styled('li')<{isChecked?: boolean}>`
-  display: grid;
-  grid-template-columns: 1fr max-content;
-  grid-column-gap: ${space(1)};
-  align-items: center;
-  padding: ${space(1)} ${space(2)};
-  border-bottom: 1px solid ${p => p.theme.border};
-  :hover {
-    background-color: ${p => p.theme.backgroundSecondary};
-  }
-  ${CheckboxFancy} {
-    opacity: ${p => (p.isChecked ? 1 : 0.3)};
-  }
-
-  &:hover ${CheckboxFancy} {
-    opacity: 1;
-  }
-
-  &:hover span {
-    color: ${p => p.theme.blue300};
-    text-decoration: underline;
-  }
-`;
-
 const StyledPanelTable = styled(PanelTable)<{
   showTeamCol: boolean;
   hasAlertList: boolean;

+ 125 - 0
static/app/views/alerts/rules/teamFilter.tsx

@@ -0,0 +1,125 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import Input from 'app/components/forms/input';
+import {t} from 'app/locale';
+import {Team} from 'app/types';
+
+import Filter from './filter';
+
+const ALERT_LIST_QUERY_DEFAULT_TEAMS = ['myteams', 'unassigned'];
+
+type Props = {
+  teams: Team[];
+  selectedTeams: Set<string>;
+  handleChangeFilter: (sectionId: string, activeFilters: Set<string>) => void;
+  showStatus?: boolean;
+  selectedStatus?: Set<string>;
+};
+
+export function getTeamParams(team?: string | string[]): string[] {
+  if (team === undefined) {
+    return ALERT_LIST_QUERY_DEFAULT_TEAMS;
+  }
+
+  if (team === '') {
+    return [];
+  }
+
+  if (Array.isArray(team)) {
+    return team;
+  }
+
+  return [team];
+}
+
+function TeamFilter({
+  teams,
+  selectedTeams,
+  showStatus = false,
+  selectedStatus = new Set(),
+  handleChangeFilter,
+}: Props) {
+  const [teamFilterSearch, setTeamFilterSearch] = useState<string | undefined>();
+
+  const statusOptions = [
+    {
+      label: t('Unresolved'),
+      value: 'open',
+      checked: selectedStatus.has('open'),
+      filtered: false,
+    },
+    {
+      label: t('Resolved'),
+      value: 'closed',
+      checked: selectedStatus.has('closed'),
+      filtered: false,
+    },
+  ];
+
+  const additionalOptions = [
+    {
+      label: t('My Teams'),
+      value: 'myteams',
+      checked: selectedTeams.has('myteams'),
+      filtered: false,
+    },
+    {
+      label: t('Unassigned'),
+      value: 'unassigned',
+      checked: selectedTeams.has('unassigned'),
+      filtered: false,
+    },
+  ];
+  const teamItems = teams.map(({id, name}) => ({
+    label: name,
+    value: id,
+    filtered: teamFilterSearch
+      ? name.toLowerCase().includes(teamFilterSearch.toLowerCase())
+      : true,
+    checked: selectedTeams.has(id),
+  }));
+
+  return (
+    <Filter
+      header={
+        <StyledInput
+          autoFocus
+          placeholder={t('Filter by team name')}
+          onClick={event => {
+            event.stopPropagation();
+          }}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+            setTeamFilterSearch(event.target.value);
+          }}
+          value={teamFilterSearch || ''}
+        />
+      }
+      onFilterChange={handleChangeFilter}
+      dropdownSections={[
+        ...(showStatus
+          ? [
+              {
+                id: 'status',
+                label: t('Status'),
+                items: statusOptions,
+              },
+            ]
+          : []),
+        {
+          id: 'teams',
+          label: t('Teams'),
+          items: [...additionalOptions, ...teamItems],
+        },
+      ]}
+    />
+  );
+}
+
+export default TeamFilter;
+
+const StyledInput = styled(Input)`
+  border: none;
+  border-bottom: 1px solid ${p => p.theme.gray200};
+  border-radius: 0;
+`;

+ 0 - 1
tests/acceptance/test_incidents.py

@@ -36,7 +36,6 @@ class OrganizationIncidentsListTest(AcceptanceTestCase, SnubaTestCase):
             self.browser.get(self.path)
             self.browser.wait_until_not(".loading-indicator")
             self.browser.wait_until_not('[data-test-id="loading-placeholder"]')
-            self.browser.wait_until_test_id("incident-sparkline")
             self.browser.snapshot("incidents - list")
 
             details_url = (

+ 46 - 26
tests/js/spec/views/alerts/list/index.spec.jsx

@@ -5,13 +5,10 @@ import ProjectsStore from 'app/stores/projectsStore';
 import IncidentsList from 'app/views/alerts/list';
 
 describe('IncidentsList', function () {
-  const {routerContext, organization} = initializeOrg({
-    organization: {
-      features: ['incidents'],
-    },
-  });
+  let routerContext;
+  let router;
+  let organization;
   let incidentsMock;
-  let statsMock;
   let projectMock;
   let wrapper;
   let projects;
@@ -23,17 +20,26 @@ describe('IncidentsList', function () {
       <IncidentsList
         params={{orgId: organization.slug}}
         location={{query: {}, search: ''}}
+        router={router}
         {...props}
       />,
       routerContext
     );
-    // Wait for sparklines library
     await tick();
     wrapper.update();
     return wrapper;
   };
 
   beforeEach(function () {
+    const context = initializeOrg({
+      organization: {
+        features: ['incidents'],
+      },
+    });
+    routerContext = context.routerContext;
+    router = context.router;
+    organization = context.organization;
+
     incidentsMock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/incidents/',
       body: [
@@ -51,10 +57,6 @@ describe('IncidentsList', function () {
         }),
       ],
     });
-    statsMock = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/incidents/1/stats/',
-      body: TestStubs.IncidentStats(),
-    });
 
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/incidents/2/stats/',
@@ -168,9 +170,7 @@ describe('IncidentsList', function () {
     wrapper.update();
 
     expect(wrapper.find('PanelItem')).toHaveLength(0);
-    expect(wrapper.text()).toContain(
-      'No metric alert rules exist for the selected projects'
-    );
+    expect(wrapper.text()).toContain('No incidents exist for the current query');
   });
 
   it('displays empty state (rules created)', async function () {
@@ -196,9 +196,7 @@ describe('IncidentsList', function () {
     wrapper.update();
 
     expect(wrapper.find('PanelItem')).toHaveLength(0);
-    expect(wrapper.text()).toContain(
-      'No unresolved metric alerts in the selected projects'
-    );
+    expect(wrapper.text()).toContain('No incidents exist for the current query.');
   });
 
   it('toggles open/closed', async function () {
@@ -216,28 +214,26 @@ describe('IncidentsList', function () {
 
     expect(incidentsMock).toHaveBeenCalledWith(
       '/organizations/org-slug/incidents/',
-      expect.objectContaining({query: {status: 'open'}})
+      expect.objectContaining({query: {status: ['open']}})
     );
 
-    wrapper.setProps({location: {query: {status: 'closed'}, search: '?status=closed`'}});
+    wrapper.setProps({
+      location: {query: {status: ['closed']}, search: '?status=closed`'},
+    });
 
     expect(wrapper.find('StyledButtonBar').find('Button').at(1).prop('priority')).toBe(
       'primary'
     );
 
-    expect(wrapper.find('IncidentPanelItem').at(0).find('Duration').text()).toBe(
-      '2 weeks'
-    );
+    expect(wrapper.find('IncidentPanelItem').at(0).text()).toContain('Still Active');
 
-    expect(wrapper.find('IncidentPanelItem').at(0).find('TimeSince')).toHaveLength(2);
+    expect(wrapper.find('IncidentPanelItem').at(0).find('TimeSince')).toHaveLength(1);
 
     expect(incidentsMock).toHaveBeenCalledTimes(2);
-    // Stats not called for closed incidents
-    expect(statsMock).toHaveBeenCalledTimes(1);
 
     expect(incidentsMock).toHaveBeenCalledWith(
       '/organizations/org-slug/incidents/',
-      expect.objectContaining({query: expect.objectContaining({status: 'closed'})})
+      expect.objectContaining({query: expect.objectContaining({status: ['closed']})})
     );
   });
 
@@ -258,4 +254,28 @@ describe('IncidentsList', function () {
     const addLink = wrapper.find('button[aria-label="Create Alert Rule"]');
     expect(addLink.props()['aria-disabled']).toBe(false);
   });
+
+  it('searches by name', async () => {
+    const org = {
+      ...organization,
+      features: ['incidents', 'alert-history-filters'],
+    };
+    wrapper = await createWrapper({organization: org});
+    expect(wrapper.find('StyledSearchBar').exists()).toBe(true);
+
+    const testQuery = 'test name';
+    wrapper
+      .find('StyledSearchBar')
+      .find('input')
+      .simulate('change', {target: {value: testQuery}})
+      .simulate('submit', {preventDefault() {}});
+
+    expect(router.push).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: {
+          title: testQuery,
+        },
+      })
+    );
+  });
 });

+ 17 - 0
tests/js/spec/views/alerts/rules/teamFilter.spec.jsx

@@ -0,0 +1,17 @@
+import {getTeamParams} from 'app/views/alerts/rules/teamFilter';
+
+describe('getTeamParams', () => {
+  it('should use default teams', () => {
+    expect(getTeamParams()).toEqual(['myteams', 'unassigned']);
+  });
+  it('should allow no teams with an empty string param', () => {
+    expect(getTeamParams('')).toEqual([]);
+  });
+  it('should allow one or more teams', () => {
+    expect(getTeamParams('team-sentry')).toEqual(['team-sentry']);
+    expect(getTeamParams(['team-sentry', 'team-two'])).toEqual([
+      'team-sentry',
+      'team-two',
+    ]);
+  });
+});