Browse Source

ref(projects): Convert project latest alerts to functional (#54373)

Scott Cooper 1 year ago
parent
commit
e614268f61

+ 26 - 8
static/app/views/projectDetail/projectLatestAlerts.spec.tsx

@@ -2,7 +2,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 import {textWithMarkupMatcher} from 'sentry-test/utils';
 
-import ProjectLatestAlerts from 'sentry/views/projectDetail/projectLatestAlerts';
+import ProjectLatestAlerts from './projectLatestAlerts';
 
 describe('ProjectDetail > ProjectLatestAlerts', function () {
   let endpointMock: jest.Mock;
@@ -28,7 +28,7 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
     MockApiClient.clearMockResponses();
   });
 
-  it('renders a list', function () {
+  it('renders a list', async function () {
     render(
       <ProjectLatestAlerts
         organization={organization}
@@ -49,7 +49,7 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
     );
 
     expect(screen.getByText('Latest Alerts')).toBeInTheDocument();
-    expect(screen.getAllByText('Too many Chrome errors')).toHaveLength(3);
+    expect(await screen.findAllByText('Too many Chrome errors')).toHaveLength(3);
 
     expect(
       screen.getAllByRole('link', {name: 'Too many Chrome errors'})[0]
@@ -87,14 +87,13 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
       />
     );
 
+    expect(await screen.findByText('No alerts found')).toBeInTheDocument();
     expect(rulesEndpointMock).toHaveBeenCalledWith(
       expect.anything(),
       expect.objectContaining({
-        query: {per_page: 1},
+        query: {per_page: 1, asc: 1},
       })
     );
-
-    expect(await screen.findByText('No alerts found')).toBeInTheDocument();
   });
 
   it('shows configure alerts buttons', async function () {
@@ -148,7 +147,7 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
     );
   });
 
-  it('handles null dateClosed with resolved alerts', function () {
+  it('handles null dateClosed with resolved alerts', async function () {
     MockApiClient.addMockResponse({
       url: `/organizations/${organization.slug}/incidents/`,
       body: [
@@ -167,7 +166,7 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
       />
     );
 
-    expect(screen.getByText('Resolved')).toBeInTheDocument();
+    expect(await screen.findByText('Resolved')).toBeInTheDocument();
   });
 
   it('does not call API if project is not stabilized yet', function () {
@@ -182,4 +181,23 @@ describe('ProjectDetail > ProjectLatestAlerts', function () {
 
     expect(endpointMock).toHaveBeenCalledTimes(0);
   });
+
+  it('renders error state', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/incidents/`,
+      body: [],
+      statusCode: 500,
+    });
+
+    render(
+      <ProjectLatestAlerts
+        organization={organization}
+        projectSlug={project.slug}
+        location={router.location}
+        isProjectStabilized
+      />
+    );
+
+    expect(await screen.findByText('Unable to load latest alerts')).toBeInTheDocument();
+  });
 });

+ 135 - 178
static/app/views/projectDetail/projectLatestAlerts.tsx

@@ -1,196 +1,144 @@
 import styled from '@emotion/styled';
-import {Location} from 'history';
+import type {Location} from 'history';
 import pick from 'lodash/pick';
 
 import AlertBadge from 'sentry/components/alertBadge';
 import {SectionHeading} from 'sentry/components/charts/styles';
-import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import Link from 'sentry/components/links/link';
+import LoadingError from 'sentry/components/loadingError';
 import Placeholder from 'sentry/components/placeholder';
 import TimeSince from 'sentry/components/timeSince';
 import {URL_PARAM} from 'sentry/constants/pageFilters';
 import {IconCheckmark, IconExclamation, IconFire, IconOpen} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
+import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
-
-import {Incident, IncidentStatus} from '../alerts/types';
+import type {Organization} from 'sentry/types';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {Incident, IncidentStatus} from 'sentry/views/alerts/types';
 
 import MissingAlertsButtons from './missingFeatureButtons/missingAlertsButtons';
 import {SectionHeadingLink, SectionHeadingWrapper, SidebarSection} from './styles';
-import {didProjectOrEnvironmentChange} from './utils';
 
 const PLACEHOLDER_AND_EMPTY_HEIGHT = '172px';
 
-type Props = DeprecatedAsyncComponent['props'] & {
+interface AlertRowProps {
+  alert: Incident;
+  orgSlug: string;
+}
+
+function AlertRow({alert, orgSlug}: AlertRowProps) {
+  const {status, identifier, title, dateClosed, dateStarted} = alert;
+  const isResolved = status === IncidentStatus.CLOSED;
+  const isWarning = status === IncidentStatus.WARNING;
+
+  const Icon = isResolved ? IconCheckmark : isWarning ? IconExclamation : IconFire;
+
+  const statusProps = {isResolved, isWarning};
+
+  return (
+    <AlertRowLink
+      aria-label={title}
+      to={`/organizations/${orgSlug}/alerts/${identifier}/`}
+    >
+      <AlertBadgeWrapper {...statusProps} icon={Icon}>
+        <AlertBadge status={status} />
+      </AlertBadgeWrapper>
+      <AlertDetails>
+        <AlertTitle>{title}</AlertTitle>
+        <AlertDate {...statusProps}>
+          {isResolved ? t('Resolved') : t('Triggered')}{' '}
+          {isResolved ? (
+            dateClosed ? (
+              <TimeSince date={dateClosed} />
+            ) : null
+          ) : (
+            <TimeSince
+              date={dateStarted}
+              tooltipUnderlineColor={getStatusColor(statusProps)}
+            />
+          )}
+        </AlertDate>
+      </AlertDetails>
+    </AlertRowLink>
+  );
+}
+
+interface ProjectLatestAlertsProps {
   isProjectStabilized: boolean;
   location: Location;
   organization: Organization;
   projectSlug: string;
-};
-
-type State = {
-  resolvedAlerts: Incident[] | null;
-  unresolvedAlerts: Incident[] | null;
-  hasAlertRule?: boolean;
-} & DeprecatedAsyncComponent['state'];
-
-class ProjectLatestAlerts extends DeprecatedAsyncComponent<Props, State> {
-  shouldComponentUpdate(nextProps: Props, nextState: State) {
-    const {location, isProjectStabilized} = this.props;
-    // TODO(project-detail): we temporarily removed refetching based on timeselector
-    if (
-      this.state !== nextState ||
-      didProjectOrEnvironmentChange(location, nextProps.location) ||
-      isProjectStabilized !== nextProps.isProjectStabilized
-    ) {
-      return true;
-    }
-
-    return false;
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    const {location, isProjectStabilized} = this.props;
-
-    if (
-      didProjectOrEnvironmentChange(prevProps.location, location) ||
-      prevProps.isProjectStabilized !== isProjectStabilized
-    ) {
-      this.remountComponent();
-    }
-  }
-
-  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
-    const {location, organization, isProjectStabilized} = this.props;
-
-    if (!isProjectStabilized) {
-      return [];
-    }
-
-    const query = {
-      ...pick(location.query, Object.values(URL_PARAM)),
-      per_page: 3,
-    };
-
-    // we are listing 3 alerts total, first unresolved and then we fill with resolved
-    return [
-      [
-        'unresolvedAlerts',
-        `/organizations/${organization.slug}/incidents/`,
-        {query: {...query, status: 'open'}},
-      ],
-      [
-        'resolvedAlerts',
-        `/organizations/${organization.slug}/incidents/`,
-        {query: {...query, status: 'closed'}},
-      ],
-    ];
-  }
-
-  /**
-   * If our alerts are empty, determine if we've configured alert rules (empty message differs then)
-   */
-  async onLoadAllEndpointsSuccess() {
-    const {unresolvedAlerts, resolvedAlerts} = this.state;
-    const {location, organization, isProjectStabilized} = this.props;
-
-    if (!isProjectStabilized) {
-      return;
-    }
-
-    if ([...(unresolvedAlerts ?? []), ...(resolvedAlerts ?? [])].length !== 0) {
-      this.setState({hasAlertRule: true});
-      return;
-    }
-
-    this.setState({loading: true});
+}
 
-    const alertRules = await this.api.requestPromise(
+function ProjectLatestAlerts({
+  location,
+  organization,
+  isProjectStabilized,
+  projectSlug,
+}: ProjectLatestAlertsProps) {
+  const query = {
+    ...pick(location.query, Object.values(URL_PARAM)),
+    per_page: 3,
+  };
+  const {
+    data: unresolvedAlerts = [],
+    isLoading: unresolvedAlertsIsLoading,
+    isError: unresolvedAlertsIsError,
+  } = useApiQuery<Incident[]>(
+    [
+      `/organizations/${organization.slug}/incidents/`,
+      {query: {...query, status: 'open'}},
+    ],
+    {staleTime: 0, enabled: isProjectStabilized}
+  );
+  const {
+    data: resolvedAlerts = [],
+    isLoading: resolvedAlertsIsLoading,
+    isError: resolvedAlertsIsError,
+  } = useApiQuery<Incident[]>(
+    [
+      `/organizations/${organization.slug}/incidents/`,
+      {query: {...query, status: 'closed'}},
+    ],
+    {staleTime: 0, enabled: isProjectStabilized}
+  );
+
+  const alertsUnresolvedAndResolved = [...unresolvedAlerts, ...resolvedAlerts];
+  const shouldLoadAlertRules =
+    alertsUnresolvedAndResolved.length === 0 &&
+    !unresolvedAlertsIsLoading &&
+    !resolvedAlertsIsLoading;
+  // This is only used to determine if we should show the "Create Alert" button
+  const {data: alertRules = [], isLoading: alertRulesLoading} = useApiQuery<any[]>(
+    [
       `/organizations/${organization.slug}/alert-rules/`,
       {
-        method: 'GET',
         query: {
-          ...pick(location.query, [...Object.values(URL_PARAM)]),
+          ...pick(location.query, Object.values(URL_PARAM)),
+          // Sort by name
+          asc: 1,
           per_page: 1,
         },
-      }
-    );
-
-    this.setState({hasAlertRule: alertRules.length > 0, loading: false});
-  }
-
-  get alertsLink() {
-    const {organization} = this.props;
-
-    // as this is a link to latest alerts, we want to only preserve project and environment
-    return {
-      pathname: `/organizations/${organization.slug}/alerts/`,
-      query: {
-        statsPeriod: undefined,
-        start: undefined,
-        end: undefined,
-        utc: undefined,
       },
-    };
-  }
-
-  renderAlertRow = (alert: Incident) => {
-    const {organization} = this.props;
-    const {status, id, identifier, title, dateClosed, dateStarted} = alert;
-    const isResolved = status === IncidentStatus.CLOSED;
-    const isWarning = status === IncidentStatus.WARNING;
-
-    const Icon = isResolved ? IconCheckmark : isWarning ? IconExclamation : IconFire;
-
-    const statusProps = {isResolved, isWarning};
-
-    return (
-      <AlertRowLink
-        aria-label={title}
-        to={`/organizations/${organization.slug}/alerts/${identifier}/`}
-        key={id}
-      >
-        <AlertBadgeWrapper {...statusProps} icon={Icon}>
-          <AlertBadge status={status} />
-        </AlertBadgeWrapper>
-        <AlertDetails>
-          <AlertTitle>{title}</AlertTitle>
-          <AlertDate {...statusProps}>
-            {isResolved
-              ? tct('Resolved [date]', {
-                  date: dateClosed ? <TimeSince date={dateClosed} /> : null,
-                })
-              : tct('Triggered [date]', {
-                  date: (
-                    <TimeSince
-                      date={dateStarted}
-                      tooltipUnderlineColor={getStatusColor(statusProps)}
-                    />
-                  ),
-                })}
-          </AlertDate>
-        </AlertDetails>
-      </AlertRowLink>
-    );
-  };
+    ],
+    {
+      staleTime: 0,
+      enabled: shouldLoadAlertRules,
+    }
+  );
 
-  renderInnerBody() {
-    const {organization, projectSlug, isProjectStabilized} = this.props;
-    const {loading, unresolvedAlerts, resolvedAlerts, hasAlertRule} = this.state;
-    const alertsUnresolvedAndResolved = [
-      ...(unresolvedAlerts ?? []),
-      ...(resolvedAlerts ?? []),
-    ];
-    const checkingForAlertRules =
-      alertsUnresolvedAndResolved.length === 0 && hasAlertRule === undefined;
-    const showLoadingIndicator = loading || checkingForAlertRules || !isProjectStabilized;
+  function renderAlertRules() {
+    if (unresolvedAlertsIsError || resolvedAlertsIsError) {
+      return <LoadingError message={t('Unable to load latest alerts')} />;
+    }
 
-    if (showLoadingIndicator) {
+    const isLoading = unresolvedAlertsIsLoading || resolvedAlertsIsLoading;
+    if (isLoading || (shouldLoadAlertRules && alertRulesLoading)) {
       return <Placeholder height={PLACEHOLDER_AND_EMPTY_HEIGHT} />;
     }
 
+    const hasAlertRule = alertsUnresolvedAndResolved.length > 0 || alertRules?.length > 0;
     if (!hasAlertRule) {
       return (
         <MissingAlertsButtons organization={organization} projectSlug={projectSlug} />
@@ -203,27 +151,36 @@ class ProjectLatestAlerts extends DeprecatedAsyncComponent<Props, State> {
       );
     }
 
-    return alertsUnresolvedAndResolved.slice(0, 3).map(this.renderAlertRow);
-  }
-
-  renderLoading() {
-    return this.renderBody();
+    return alertsUnresolvedAndResolved
+      .slice(0, 3)
+      .map(alert => (
+        <AlertRow key={alert.id} alert={alert} orgSlug={organization.slug} />
+      ));
   }
 
-  renderBody() {
-    return (
-      <SidebarSection>
-        <SectionHeadingWrapper>
-          <SectionHeading>{t('Latest Alerts')}</SectionHeading>
-          <SectionHeadingLink to={this.alertsLink}>
-            <IconOpen />
-          </SectionHeadingLink>
-        </SectionHeadingWrapper>
-
-        <div>{this.renderInnerBody()}</div>
-      </SidebarSection>
-    );
-  }
+  return (
+    <SidebarSection>
+      <SectionHeadingWrapper>
+        <SectionHeading>{t('Latest Alerts')}</SectionHeading>
+        {/* as this is a link to latest alerts, we want to only preserve project and environment */}
+        <SectionHeadingLink
+          to={{
+            pathname: `/organizations/${organization.slug}/alerts/`,
+            query: {
+              statsPeriod: undefined,
+              start: undefined,
+              end: undefined,
+              utc: undefined,
+            },
+          }}
+        >
+          <IconOpen aria-label={t('Metric Alert History')} />
+        </SectionHeadingLink>
+      </SectionHeadingWrapper>
+
+      <div>{renderAlertRules()}</div>
+    </SidebarSection>
+  );
 }
 
 const AlertRowLink = styled(Link)`