Browse Source

feat(alerts): Refactor Alert Settings to FC (#62516)

Scott Cooper 1 year ago
parent
commit
1ce56889f9

+ 52 - 1
fixtures/js-stubs/integrationListDirectory.ts

@@ -1,4 +1,4 @@
-import {Integration, IntegrationProvider, SentryApp} from 'sentry/types';
+import type {Integration, IntegrationProvider, Plugin, SentryApp} from 'sentry/types';
 
 export function ProviderListFixture(): {providers: IntegrationProvider[]} {
   return {
@@ -295,3 +295,54 @@ export function PluginListConfigFixture() {
     },
   ];
 }
+
+export function WebhookPluginConfigFixture(plugin?: Partial<Plugin>): Plugin {
+  return {
+    id: 'webhooks',
+    name: 'WebHooks',
+    slug: 'webhooks',
+    shortName: 'WebHooks',
+    type: 'notification',
+    canDisable: true,
+    isTestable: true,
+    hasConfiguration: true,
+    metadata: {},
+    contexts: [],
+    status: 'unknown',
+    assets: [],
+    doc: '',
+    enabled: true,
+    version: '24.1.0.dev0',
+    author: {
+      name: 'Sentry Team',
+      url: 'https://github.com/getsentry/sentry',
+    },
+    isDeprecated: false,
+    isHidden: false,
+    description:
+      '\nTrigger outgoing HTTP POST requests from Sentry.\n\nNote: To configure webhooks over multiple projects, we recommend setting up an\nInternal Integration.\n',
+    features: ['alert-rule'],
+    featureDescriptions: [
+      {
+        description: 'Configure rule based outgoing HTTP POST requests from Sentry.',
+        featureGate: 'alert-rule',
+        featureId: 1,
+      },
+    ],
+    resourceLinks: [
+      {
+        title: 'Report Issue',
+        url: 'https://github.com/getsentry/sentry/issues',
+      },
+      {
+        title: 'View Source',
+        url: 'https://github.com/getsentry/sentry/tree/master/src/sentry/plugins/sentry_webhooks',
+      },
+      {
+        title: 'Internal Integrations',
+        url: 'https://docs.sentry.io/workflow/integrations/integration-platform/#internal-integrations',
+      },
+    ],
+    ...plugin,
+  };
+}

+ 1 - 2
static/app/views/settings/projectAlerts/index.tsx

@@ -11,14 +11,13 @@ interface Props
   project: Project;
 }
 
-function ProjectAlerts({children, organization, project}: Props) {
+function ProjectAlerts({children, project}: Props) {
   return (
     <Access access={['project:write']} project={project}>
       {({hasAccess}) => (
         <Fragment>
           {isValidElement(children) &&
             cloneElement<any>(children, {
-              organization,
               canEditRule: hasAccess,
             })}
         </Fragment>

+ 48 - 21
static/app/views/settings/projectAlerts/settings.spec.tsx

@@ -1,22 +1,27 @@
-import {OrganizationFixture} from 'sentry-fixture/organization';
+import {WebhookPluginConfigFixture} from 'sentry-fixture/integrationListDirectory';
 import {ProjectFixture} from 'sentry-fixture/project';
-import {RouterFixture} from 'sentry-fixture/routerFixture';
 
-import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
-import Settings from 'sentry/views/settings/projectAlerts/settings';
+import ProjectAlertSettings from 'sentry/views/settings/projectAlerts/settings';
 
 describe('ProjectAlertSettings', () => {
-  const router = RouterFixture();
-  const organization = OrganizationFixture();
   // 12 minutes
   const digestsMinDelay = 12 * 60;
   // 55 minutes
   const digestsMaxDelay = 55 * 60;
+
   const project = ProjectFixture({
     digestsMinDelay,
     digestsMaxDelay,
   });
+  const {organization, routerProps} = initializeOrg({
+    project,
+    router: {
+      params: {projectId: project.slug},
+    },
+  });
 
   beforeEach(() => {
     MockApiClient.addMockResponse({
@@ -32,21 +37,12 @@ describe('ProjectAlertSettings', () => {
     });
   });
 
-  it('renders', () => {
-    render(
-      <Settings
-        canEditRule
-        params={{projectId: project.slug}}
-        organization={organization}
-        routes={[]}
-        router={router}
-        routeParams={router.params}
-        route={router.routes[0]}
-        location={router.location}
-      />
-    );
-
-    expect(screen.getByPlaceholderText('e.g. $shortID - $title')).toBeInTheDocument();
+  it('renders', async () => {
+    render(<ProjectAlertSettings canEditRule {...routerProps} />);
+
+    expect(
+      await screen.findByPlaceholderText('e.g. $shortID - $title')
+    ).toBeInTheDocument();
     expect(
       screen.getByRole('slider', {name: 'Minimum delivery interval'})
     ).toBeInTheDocument();
@@ -59,4 +55,35 @@ describe('ProjectAlertSettings', () => {
       )
     ).toBeInTheDocument();
   });
+
+  it('enables webhook integration', async () => {
+    const pluginConfig = WebhookPluginConfigFixture({enabled: false});
+
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/plugins/`,
+      method: 'GET',
+      body: [pluginConfig],
+    });
+    const enabledPluginMock = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/plugins/${pluginConfig.id}/`,
+      method: 'POST',
+      body: '',
+    });
+    const getWebhookMock = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/plugins/${pluginConfig.id}/`,
+      method: 'GET',
+      body: [{...pluginConfig, enabled: true}],
+    });
+
+    render(<ProjectAlertSettings canEditRule {...routerProps} />);
+
+    expect(
+      await screen.findByPlaceholderText('e.g. $shortID - $title')
+    ).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', {name: 'WebHooks'}));
+
+    expect(await screen.findByRole('button', {name: 'Test Plugin'})).toBeInTheDocument();
+    expect(enabledPluginMock).toHaveBeenCalled();
+    expect(getWebhookMock).toHaveBeenCalled();
+  });
 });

+ 151 - 142
static/app/views/settings/projectAlerts/settings.tsx

@@ -1,168 +1,177 @@
 import {Fragment} from 'react';
-import {RouteComponentProps} from 'react-router';
+import type {RouteComponentProps} from 'react-router';
 
 import AlertLink from 'sentry/components/alertLink';
-import {Button} from 'sentry/components/button';
+import {LinkButton} from 'sentry/components/button';
 import Form from 'sentry/components/forms/form';
 import JsonForm from 'sentry/components/forms/jsonForm';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import PanelAlert from 'sentry/components/panels/panelAlert';
 import PluginList from 'sentry/components/pluginList';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {fields} from 'sentry/data/forms/projectAlerts';
 import {IconMail} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {Organization, Plugin, Project} from 'sentry/types';
+import type {Plugin, Project} from 'sentry/types';
+import {
+  ApiQueryKey,
+  setApiQueryData,
+  useApiQuery,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
 import routeTitleGen from 'sentry/utils/routeTitle';
-import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
+import useOrganization from 'sentry/utils/useOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
 
-type RouteParams = {projectId: string};
-
-type Props = RouteComponentProps<RouteParams, {}> &
-  DeprecatedAsyncView['props'] & {
-    canEditRule: boolean;
-    organization: Organization;
-  };
-
-type State = DeprecatedAsyncView['state'] & {
-  pluginList: Array<Plugin> | null;
-  project: Project | null;
-};
-
-class Settings extends DeprecatedAsyncView<Props, State> {
-  getDefaultState() {
-    return {
-      ...super.getDefaultState(),
-      project: null,
-      pluginList: [],
-    };
-  }
+interface ProjectAlertSettingsProps extends RouteComponentProps<{projectId: string}, {}> {
+  canEditRule: boolean;
+}
 
-  getProjectEndpoint() {
-    const {organization, params} = this.props;
-    return `/projects/${organization.slug}/${params.projectId}/`;
-  }
+function makeFetchProjectPluginsQueryKey(
+  organizationSlug: string,
+  projectSlug: string
+): ApiQueryKey {
+  return [`/projects/${organizationSlug}/${projectSlug}/plugins/`];
+}
 
-  getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
-    const {organization, params} = this.props;
-    const projectEndpoint = this.getProjectEndpoint();
-    return [
-      ['project', projectEndpoint],
-      ['pluginList', `/projects/${organization.slug}/${params.projectId}/plugins/`],
-    ];
+function ProjectAlertSettings({canEditRule, params}: ProjectAlertSettingsProps) {
+  const organization = useOrganization();
+  const queryClient = useQueryClient();
+
+  const projectSlug = params.projectId;
+  const {
+    data: project,
+    isLoading: isProjectLoading,
+    isError: isProjectError,
+    refetch: refetchProject,
+  } = useApiQuery<Project>([`/projects/${organization.slug}/${projectSlug}/`], {
+    staleTime: 0,
+    cacheTime: 0,
+  });
+  const {
+    data: pluginList = [],
+    isLoading: isPluginListLoading,
+    isError: isPluginListError,
+    refetch: refetchPluginList,
+  } = useApiQuery<Plugin[]>(
+    makeFetchProjectPluginsQueryKey(organization.slug, projectSlug),
+    {staleTime: 0, cacheTime: 0}
+  );
+
+  if ((!isProjectLoading && !project) || isPluginListError || isProjectError) {
+    return (
+      <LoadingError
+        onRetry={() => {
+          isProjectError && refetchProject();
+          isPluginListError && refetchPluginList();
+        }}
+      />
+    );
   }
 
-  handleEnablePlugin = (plugin: Plugin) => {
-    this.setState(prevState => ({
-      pluginList: (prevState.pluginList ?? []).map(p => {
-        if (p.id !== plugin.id) {
-          return p;
-        }
-        return {
-          ...plugin,
-          enabled: true,
-        };
-      }),
-    }));
+  const updatePlugin = (plugin: Plugin, enabled: boolean) => {
+    setApiQueryData<Plugin[]>(
+      queryClient,
+      makeFetchProjectPluginsQueryKey(organization.slug, projectSlug),
+      oldState =>
+        oldState.map(p => {
+          if (p.id !== plugin.id) {
+            return p;
+          }
+          return {
+            ...plugin,
+            enabled,
+          };
+        })
+    );
   };
 
-  handleDisablePlugin = (plugin: Plugin) => {
-    this.setState(prevState => ({
-      pluginList: (prevState.pluginList ?? []).map(p => {
-        if (p.id !== plugin.id) {
-          return p;
-        }
-        return {
-          ...plugin,
-          enabled: false,
-        };
-      }),
-    }));
+  const handleEnablePlugin = (plugin: Plugin) => {
+    updatePlugin(plugin, true);
   };
 
-  getTitle() {
-    const {projectId} = this.props.params;
-    return routeTitleGen(t('Alerts Settings'), projectId, false);
-  }
-
-  renderBody() {
-    const {canEditRule, organization} = this.props;
-    const {project, pluginList} = this.state;
-
-    if (!project) {
-      return null;
-    }
-
-    const projectEndpoint = this.getProjectEndpoint();
-
-    return (
-      <Fragment>
-        <SettingsPageHeader
-          title={t('Alerts Settings')}
-          action={
-            <Button
-              to={{
-                pathname: `/organizations/${organization.slug}/alerts/rules/`,
-                query: {project: project.id},
-              }}
-              size="sm"
-            >
-              {t('View Alert Rules')}
-            </Button>
-          }
-        />
-        <PermissionAlert project={project} />
-        <AlertLink to="/settings/account/notifications/" icon={<IconMail />}>
-          {t(
-            'Looking to fine-tune your personal notification preferences? Visit your Account Settings'
-          )}
-        </AlertLink>
-
-        <Form
-          saveOnBlur
-          allowUndo
-          initialData={{
-            subjectTemplate: project.subjectTemplate,
-            digestsMinDelay: project.digestsMinDelay,
-            digestsMaxDelay: project.digestsMaxDelay,
-          }}
-          apiMethod="PUT"
-          apiEndpoint={projectEndpoint}
-        >
-          <JsonForm
-            disabled={!canEditRule}
-            title={t('Email Settings')}
-            fields={[fields.subjectTemplate]}
-          />
-
-          <JsonForm
-            title={t('Digests')}
-            disabled={!canEditRule}
-            fields={[fields.digestsMinDelay, fields.digestsMaxDelay]}
-            renderHeader={() => (
-              <PanelAlert type="info">
-                {t(
-                  'Sentry will automatically digest alerts sent by some services to avoid flooding your inbox with individual issue notifications. To control how frequently notifications are delivered, use the sliders below.'
-                )}
-              </PanelAlert>
-            )}
-          />
-        </Form>
+  const handleDisablePlugin = (plugin: Plugin) => {
+    updatePlugin(plugin, false);
+  };
 
-        {canEditRule && (
-          <PluginList
-            organization={organization}
-            project={project}
-            pluginList={(pluginList ?? []).filter(
-              p => p.type === 'notification' && p.hasConfiguration
-            )}
-            onEnablePlugin={this.handleEnablePlugin}
-            onDisablePlugin={this.handleDisablePlugin}
-          />
+  return (
+    <Fragment>
+      <SentryDocumentTitle
+        title={routeTitleGen(t('Alerts Settings'), projectSlug, false)}
+      />
+      <SettingsPageHeader
+        title={t('Alerts Settings')}
+        action={
+          <LinkButton
+            to={{
+              pathname: `/organizations/${organization.slug}/alerts/rules/`,
+              query: {project: project?.id},
+            }}
+            size="sm"
+          >
+            {t('View Alert Rules')}
+          </LinkButton>
+        }
+      />
+      <PermissionAlert project={project} />
+      <AlertLink to="/settings/account/notifications/" icon={<IconMail />}>
+        {t(
+          'Looking to fine-tune your personal notification preferences? Visit your Account Settings'
         )}
-      </Fragment>
-    );
-  }
+      </AlertLink>
+
+      {isProjectLoading || isPluginListLoading ? (
+        <LoadingIndicator />
+      ) : (
+        <Fragment>
+          <Form
+            saveOnBlur
+            allowUndo
+            initialData={{
+              subjectTemplate: project.subjectTemplate,
+              digestsMinDelay: project.digestsMinDelay,
+              digestsMaxDelay: project.digestsMaxDelay,
+            }}
+            apiMethod="PUT"
+            apiEndpoint={`/projects/${organization.slug}/${project.slug}/`}
+          >
+            <JsonForm
+              disabled={!canEditRule}
+              title={t('Email Settings')}
+              fields={[fields.subjectTemplate]}
+            />
+
+            <JsonForm
+              title={t('Digests')}
+              disabled={!canEditRule}
+              fields={[fields.digestsMinDelay, fields.digestsMaxDelay]}
+              renderHeader={() => (
+                <PanelAlert type="info">
+                  {t(
+                    'Sentry will automatically digest alerts sent by some services to avoid flooding your inbox with individual issue notifications. To control how frequently notifications are delivered, use the sliders below.'
+                  )}
+                </PanelAlert>
+              )}
+            />
+          </Form>
+
+          {canEditRule && (
+            <PluginList
+              organization={organization}
+              project={project}
+              pluginList={(pluginList ?? []).filter(
+                p => p.type === 'notification' && p.hasConfiguration
+              )}
+              onEnablePlugin={handleEnablePlugin}
+              onDisablePlugin={handleDisablePlugin}
+            />
+          )}
+        </Fragment>
+      )}
+    </Fragment>
+  );
 }
 
-export default Settings;
+export default ProjectAlertSettings;