Browse Source

feat(onboarding): add messaging integration onboarding to project creation (#79194)

adding an option on the project creation page to send alerts to a
messaging integration (Slack, Discord, MS Teams, etc.)

email alert rules are still created as well (the checkbox to "notify via
email" is selected by default, and can not be deselected)

if org has no integrations installed:


https://github.com/user-attachments/assets/5e015fa5-f98e-4806-b39c-113bd181481f

*notice how the dropdowns to select an integration provider (slack,
discord, ms teams) and workspace are disabled, because there is only 1
choice for each

if org has multiple integrations installed:


https://github.com/user-attachments/assets/9de014eb-e89a-4532-b56f-e757fd495c0a

if the org is not on a team plan or above, the button is disabled:

<img width="785" alt="Screenshot 2024-10-16 at 9 53 32 AM"
src="https://github.com/user-attachments/assets/8d2807f9-68c2-4abe-947d-285242779323">

if the user has selected "notify via integration" but they have not
input a channel ID, the create project button is disabled:

<img width="934" alt="Screenshot 2024-10-17 at 11 42 04 AM"
src="https://github.com/user-attachments/assets/ed62f9d7-6170-45ec-98ad-387309fcfb00">

error messages are displayed at the bottom (existing behavior):

<img width="923" alt="Screenshot 2024-10-17 at 11 41 43 AM"
src="https://github.com/user-attachments/assets/a238a984-d056-48f0-9b16-ef028e43f91f">
mia hsu 4 months ago
parent
commit
6cbb256706

+ 12 - 0
static/app/components/modals/projectCreationModal.spec.tsx

@@ -1,4 +1,5 @@
 import {OrganizationFixture} from 'sentry-fixture/organization';
+import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
 import {MOCK_RESP_VERBOSE} from 'sentry-fixture/ruleConditions';
 import {TeamFixture} from 'sentry-fixture/team';
 
@@ -53,6 +54,12 @@ describe('Project Creation Modal', function () {
     const team = TeamFixture({
       access: ['team:admin', 'team:write', 'team:read'],
     });
+    const integrations = [
+      OrganizationIntegrationsFixture({
+        name: "Moo Deng's Workspace",
+        status: 'active',
+      }),
+    ];
 
     MockApiClient.addMockResponse({
       url: `/projects/${organization.slug}/rule-conditions/`,
@@ -80,6 +87,11 @@ describe('Project Creation Modal', function () {
       body: [],
     });
 
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`,
+      body: integrations,
+    });
+
     OrganizationStore.onUpdate(organization);
     TeamStore.loadUserTeams([team]);
 

+ 22 - 0
static/app/types/alerts.tsx

@@ -213,6 +213,28 @@ export type IssueAlertRuleCondition = Omit<
   [key: string]: number | string;
 };
 
+interface SlackAction {
+  channel: string | undefined;
+  id: IssueAlertActionType.SLACK;
+  workspace: string | undefined;
+  channel_id?: string | undefined;
+  notes?: string | undefined;
+  tags?: string | undefined;
+}
+interface DiscordAction {
+  channel_id: string | undefined;
+  id: IssueAlertActionType.DISCORD;
+  server: string | undefined;
+  tags?: string | undefined;
+}
+interface MSTeamsAction {
+  channel: string | undefined;
+  id: IssueAlertActionType.MS_TEAMS;
+  team: string | undefined;
+}
+
+export type IntegrationAction = SlackAction | DiscordAction | MSTeamsAction;
+
 export interface UnsavedIssueAlertRule {
   /** When an issue matches [actionMatch] of the following */
   actionMatch: 'all' | 'any' | 'none';

+ 1 - 1
static/app/utils/analytics/workflowAnalyticsEvents.tsx

@@ -147,7 +147,7 @@ export type TeamInsightsEventParameters = {
     issue_alert: 'Default' | 'Custom' | 'No Rule';
     platform: string;
     project_id: string;
-    rule_id: string;
+    rule_ids: string[];
   };
   'project_detail.change_chart': {chart_index: number; metric: string};
   'project_detail.open_anr_issues': {};

+ 38 - 1
static/app/views/projectInstall/createProject.spec.tsx

@@ -1,4 +1,5 @@
 import {OrganizationFixture} from 'sentry-fixture/organization';
+import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
 import {MOCK_RESP_VERBOSE} from 'sentry-fixture/ruleConditions';
 import {TeamFixture} from 'sentry-fixture/team';
 
@@ -47,6 +48,15 @@ function renderFrameworkModalMockRequests({
     body: [],
   });
 
+  MockApiClient.addMockResponse({
+    url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`,
+    body: [
+      OrganizationIntegrationsFixture({
+        name: "Moo Deng's Workspace",
+      }),
+    ],
+  });
+
   const projectCreationMockRequest = MockApiClient.addMockResponse({
     url: `/teams/${organization.slug}/${teamSlug}/projects/`,
     method: 'POST',
@@ -84,6 +94,15 @@ describe('CreateProject', function () {
       // Not required for these tests
       statusCode: 500,
     });
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/org-slug/integrations/?integrationType=messaging`,
+      body: [
+        OrganizationIntegrationsFixture({
+          name: "Moo Deng's Workspace",
+        }),
+      ],
+    });
   });
 
   afterEach(() => {
@@ -367,7 +386,9 @@ describe('CreateProject', function () {
   });
 
   describe('Issue Alerts Options', function () {
-    const organization = OrganizationFixture();
+    const organization = OrganizationFixture({
+      features: ['messaging-integration-onboarding-project-creation'],
+    });
     beforeEach(() => {
       TeamStore.loadUserTeams([teamWithAccess]);
 
@@ -375,6 +396,15 @@ describe('CreateProject', function () {
         url: `/projects/${organization.slug}/rule-conditions/`,
         body: MOCK_RESP_VERBOSE,
       });
+
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`,
+        body: [
+          OrganizationIntegrationsFixture({
+            name: "Moo Deng's Workspace",
+          }),
+        ],
+      });
     });
 
     afterEach(() => {
@@ -406,6 +436,13 @@ describe('CreateProject', function () {
       await userEvent.clear(screen.getByTestId('range-input'));
       expect(getSubmitButton()).toBeDisabled();
 
+      await userEvent.click(
+        screen.getByRole('checkbox', {
+          name: 'Notify via integration (Slack, Discord, MS Teams, etc.)',
+        })
+      );
+      expect(getSubmitButton()).toBeDisabled();
+
       await userEvent.click(screen.getByText("I'll create my own alerts later"));
       expect(getSubmitButton()).toBeEnabled();
     });

+ 52 - 6
static/app/views/projectInstall/createProject.tsx

@@ -35,6 +35,10 @@ import useApi from 'sentry/utils/useApi';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import {useTeams} from 'sentry/utils/useTeams';
+import {
+  MultipleCheckboxOptions,
+  useCreateNotificationAction,
+} from 'sentry/views/projectInstall/issueAlertNotificationOptions';
 import IssueAlertOptions, {
   MetricValues,
   RuleAction,
@@ -82,6 +86,8 @@ function CreateProject() {
     undefined
   );
 
+  const {createNotificationAction, notificationProps} = useCreateNotificationAction();
+
   const frameworkSelectionEnabled = !!organization?.features.includes(
     'onboarding-sdk-selection'
   );
@@ -90,6 +96,7 @@ function CreateProject() {
     async (selectedFramework?: OnboardingSelectedSDK) => {
       const {slug} = organization;
       const {
+        shouldCreateRule,
         shouldCreateCustomRule,
         name,
         conditions,
@@ -121,7 +128,7 @@ function CreateProject() {
           },
         });
 
-        let ruleId: string | undefined;
+        const ruleIds: string[] = [];
         if (shouldCreateCustomRule) {
           const ruleData = await api.requestPromise(
             `/projects/${organization.slug}/${projectData.slug}/rules/`,
@@ -136,7 +143,18 @@ function CreateProject() {
               },
             }
           );
-          ruleId = ruleData.id;
+          ruleIds.push(ruleData.id);
+        }
+        const ruleData = await createNotificationAction({
+          shouldCreateRule,
+          name,
+          projectSlug: projectData.slug,
+          conditions,
+          actionMatch,
+          frequency,
+        });
+        if (ruleData) {
+          ruleIds.push(ruleData.id);
         }
         trackAnalytics('project_creation_page.created', {
           organization,
@@ -147,7 +165,7 @@ function CreateProject() {
               : 'No Rule',
           project_id: projectData.id,
           platform: selectedPlatform.key,
-          rule_id: ruleId || '',
+          rule_ids: ruleIds,
         });
 
         ProjectsStore.onCreateSuccess(projectData, organization.slug);
@@ -192,7 +210,15 @@ function CreateProject() {
         }
       }
     },
-    [api, alertRuleConfig, organization, platform, projectName, team]
+    [
+      api,
+      alertRuleConfig,
+      organization,
+      platform,
+      projectName,
+      team,
+      createNotificationAction,
+    ]
   );
 
   const handleProjectCreation = useCallback(async () => {
@@ -259,7 +285,7 @@ function CreateProject() {
     setProjectName(newName);
   }
 
-  const {shouldCreateCustomRule, conditions} = alertRuleConfig || {};
+  const {shouldCreateRule, shouldCreateCustomRule, conditions} = alertRuleConfig || {};
   const canUserCreateProject = canCreateProject(organization);
 
   const canCreateTeam = organization.access.includes('project:admin');
@@ -269,11 +295,19 @@ function CreateProject() {
   const isMissingProjectName = projectName === '';
   const isMissingAlertThreshold =
     shouldCreateCustomRule && !conditions?.every?.(condition => condition.value);
+  const isMissingMessagingIntegrationChannel =
+    organization.features.includes('messaging-integration-onboarding-project-creation') &&
+    shouldCreateRule &&
+    notificationProps.actions?.some(
+      action => action === MultipleCheckboxOptions.INTEGRATION
+    ) &&
+    !notificationProps.channel;
 
   const formErrorCount = [
     isMissingTeam,
     isMissingProjectName,
     isMissingAlertThreshold,
+    isMissingMessagingIntegrationChannel,
   ].filter(value => value).length;
 
   const canSubmitForm = !inFlight && canUserCreateProject && formErrorCount === 0;
@@ -285,8 +319,19 @@ function CreateProject() {
     submitTooltipText = t('Please provide a project name');
   } else if (isMissingAlertThreshold) {
     submitTooltipText = t('Please provide an alert threshold');
+  } else if (isMissingMessagingIntegrationChannel) {
+    submitTooltipText = t(
+      'Please provide an integration channel for alert notifications'
+    );
   }
 
+  const keyToErrorText = {
+    actions: t('Notify via integration'),
+    conditions: t('Alert conditions'),
+    name: t('Alert name'),
+    detail: t('Project details'),
+  };
+
   const alertFrequencyDefaultValues = useMemo(() => {
     if (!autoFill) {
       return {};
@@ -348,6 +393,7 @@ function CreateProject() {
             {...alertFrequencyDefaultValues}
             platformLanguage={platform?.language as SupportedLanguages}
             onChange={updatedData => setAlertRuleConfig(updatedData)}
+            notificationProps={notificationProps}
           />
           <StyledListItem>{t('Name your project and assign it a team')}</StyledListItem>
           <CreateProjectForm
@@ -407,7 +453,7 @@ function CreateProject() {
             <Alert type="error">
               {Object.keys(errors).map(key => (
                 <div key={key}>
-                  <strong>{startCase(key)}</strong>: {errors[key]}
+                  <strong>{keyToErrorText[key] ?? startCase(key)}</strong>: {errors[key]}
                 </div>
               ))}
             </Alert>

+ 93 - 0
static/app/views/projectInstall/issueAlertNotificationOptions.spec.tsx

@@ -0,0 +1,93 @@
+import {GitHubIntegrationProviderFixture} from 'sentry-fixture/githubIntegrationProvider';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import type {OrganizationIntegration} from 'sentry/types/integrations';
+import IssueAlertNotificationOptions, {
+  type IssueAlertNotificationProps,
+} from 'sentry/views/projectInstall/issueAlertNotificationOptions';
+
+describe('MessagingIntegrationAlertRule', function () {
+  const organization = OrganizationFixture({
+    features: ['messaging-integration-onboarding-project-creation'],
+  });
+  const integrations: OrganizationIntegration[] = [];
+  const mockSetAction = jest.fn();
+
+  const notificationProps: IssueAlertNotificationProps = {
+    actions: [],
+    channel: 'channel',
+    integration: undefined,
+    provider: 'slack',
+    providersToIntegrations: {},
+    querySuccess: true,
+    shouldRenderSetupButton: false,
+    refetchConfigs: jest.fn(),
+    setActions: mockSetAction,
+    setChannel: jest.fn(),
+    setIntegration: jest.fn(),
+    setProvider: jest.fn(),
+  };
+
+  const getComponent = () => <IssueAlertNotificationOptions {...notificationProps} />;
+
+  it('renders setup button if no integrations are active', async function () {
+    const providers = (providerKey: string) => [
+      GitHubIntegrationProviderFixture({key: providerKey}),
+    ];
+    const providerKeys = ['slack', 'discord', 'msteams'];
+    const mockResponses: jest.Mock<any>[] = [];
+    providerKeys.forEach(providerKey => {
+      mockResponses.push(
+        MockApiClient.addMockResponse({
+          url: `/organizations/${organization.slug}/config/integrations/?provider_key=${providerKey}`,
+          body: {providers: providers(providerKey)},
+        })
+      );
+    });
+    mockResponses.push(
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`,
+        body: [],
+      })
+    );
+    render(
+      <IssueAlertNotificationOptions {...notificationProps} shouldRenderSetupButton />,
+      {organization: organization}
+    );
+    await screen.findByText(/notify via email/i);
+    expect(screen.queryByText(/notify via integration/i)).not.toBeInTheDocument();
+    await screen.findByRole('button', {name: /connect to messaging/i});
+    mockResponses.forEach(mock => {
+      expect(mock).toHaveBeenCalled();
+    });
+  });
+
+  it('renders alert configuration if integration is installed', async function () {
+    integrations.push(
+      OrganizationIntegrationsFixture({
+        name: "Moo Toon's Workspace",
+        status: 'active',
+      })
+    );
+    render(getComponent(), {organization: organization});
+    await screen.findByText(/notify via email/i);
+    await screen.findByText(/notify via integration/i);
+  });
+
+  it('calls setter when new integration option is selected', async function () {
+    integrations.push(
+      OrganizationIntegrationsFixture({
+        name: "Moo Toon's Workspace",
+        status: 'active',
+      })
+    );
+    render(getComponent(), {organization: organization});
+    await screen.findByText(/notify via email/i);
+    await screen.findByText(/notify via integration/i);
+    await userEvent.click(screen.getByText(/notify via integration/i));
+    expect(mockSetAction).toHaveBeenCalled();
+  });
+});

+ 277 - 0
static/app/views/projectInstall/issueAlertNotificationOptions.tsx

@@ -0,0 +1,277 @@
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import MultipleCheckbox from 'sentry/components/forms/controls/multipleCheckbox';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {type IntegrationAction, IssueAlertActionType} from 'sentry/types/alerts';
+import type {OrganizationIntegration} from 'sentry/types/integrations';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import SetupMessagingIntegrationButton, {
+  MessagingIntegrationAnalyticsView,
+} from 'sentry/views/alerts/rules/issue/setupMessagingIntegrationButton';
+import MessagingIntegrationAlertRule from 'sentry/views/projectInstall/messagingIntegrationAlertRule';
+
+export const providerDetails = {
+  slack: {
+    name: t('Slack'),
+    action: IssueAlertActionType.SLACK,
+    placeholder: t('channel, e.g. #critical'),
+    makeSentence: ({providerName, integrationName, target}) =>
+      tct(
+        'Send [providerName] notification to the [integrationName] workspace to [target]',
+        {
+          providerName,
+          integrationName,
+          target,
+        }
+      ),
+  },
+  discord: {
+    name: t('Discord'),
+    action: IssueAlertActionType.DISCORD,
+    placeholder: t('channel ID or URL'),
+    makeSentence: ({providerName, integrationName, target}) =>
+      tct(
+        'Send [providerName] notification to the [integrationName] server in the channel [target]',
+        {
+          providerName,
+          integrationName,
+          target,
+        }
+      ),
+  },
+  msteams: {
+    name: t('MS Teams'),
+    action: IssueAlertActionType.MS_TEAMS,
+    placeholder: t('channel ID'),
+    makeSentence: ({providerName, integrationName, target}) =>
+      tct('Send [providerName] notification to the [integrationName] team to [target]', {
+        providerName,
+        integrationName,
+        target,
+      }),
+  },
+};
+
+export const enum MultipleCheckboxOptions {
+  EMAIL = 'email',
+  INTEGRATION = 'integration',
+}
+
+export type IssueAlertNotificationProps = {
+  actions: MultipleCheckboxOptions[];
+  channel: string | undefined;
+  integration: OrganizationIntegration | undefined;
+  provider: string | undefined;
+  providersToIntegrations: Record<string, OrganizationIntegration[]>;
+  querySuccess: boolean;
+  refetchConfigs: () => void;
+  setActions: (action: MultipleCheckboxOptions[]) => void;
+  setChannel: (channel: string | undefined) => void;
+  setIntegration: (integration: OrganizationIntegration | undefined) => void;
+  setProvider: (provider: string | undefined) => void;
+  shouldRenderSetupButton: boolean;
+};
+
+export function useCreateNotificationAction() {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const messagingIntegrationsQuery = useApiQuery<OrganizationIntegration[]>(
+    [`/organizations/${organization.slug}/integrations/?integrationType=messaging`],
+    {staleTime: Infinity}
+  );
+
+  const providersToIntegrations = useMemo(() => {
+    const map: Record<string, OrganizationIntegration[]> = {};
+    if (messagingIntegrationsQuery.data) {
+      for (const i of messagingIntegrationsQuery.data) {
+        if (i.status === 'active') {
+          const providerSlug = i.provider.slug;
+          map[providerSlug] = map[providerSlug] ?? [];
+          map[providerSlug].push(i);
+        }
+      }
+    }
+    return map;
+  }, [messagingIntegrationsQuery.data]);
+
+  const [actions, setActions] = useState<MultipleCheckboxOptions[]>([
+    MultipleCheckboxOptions.EMAIL,
+  ]);
+  const [provider, setProvider] = useState<string | undefined>(undefined);
+  const [integration, setIntegration] = useState<OrganizationIntegration | undefined>(
+    undefined
+  );
+  const [channel, setChannel] = useState<string | undefined>(undefined);
+  const [shouldRenderSetupButton, setShouldRenderSetupButton] = useState<boolean>(false);
+
+  useEffect(() => {
+    if (messagingIntegrationsQuery.isSuccess) {
+      const providerKeys = Object.keys(providersToIntegrations);
+      const firstProvider = providerKeys[0] ?? undefined;
+      const firstIntegration = providersToIntegrations[firstProvider]?.[0] ?? undefined;
+      setProvider(firstProvider);
+      setIntegration(firstIntegration);
+      setShouldRenderSetupButton(!firstProvider);
+    }
+  }, [messagingIntegrationsQuery.isSuccess, providersToIntegrations]);
+
+  type Props = {
+    actionMatch: string | undefined;
+    conditions: {id: string; interval: string; value: string}[] | undefined;
+    frequency: number | undefined;
+    name: string | undefined;
+    projectSlug: string;
+    shouldCreateRule: boolean | undefined;
+  };
+
+  const createNotificationAction = useCallback(
+    ({
+      shouldCreateRule,
+      projectSlug,
+      name,
+      conditions,
+      actionMatch,
+      frequency,
+    }: Props) => {
+      const isCreatingIntegrationNotification = actions.find(
+        action => action === MultipleCheckboxOptions.INTEGRATION
+      );
+      if (
+        !organization.features.includes(
+          'messaging-integration-onboarding-project-creation'
+        ) ||
+        !shouldCreateRule ||
+        !isCreatingIntegrationNotification
+      ) {
+        return undefined;
+      }
+
+      let integrationAction: IntegrationAction;
+      switch (provider) {
+        case 'slack':
+          integrationAction = {
+            id: IssueAlertActionType.SLACK,
+            workspace: integration?.id,
+            channel: channel,
+          };
+
+          break;
+        case 'discord':
+          integrationAction = {
+            id: IssueAlertActionType.DISCORD,
+            server: integration?.id,
+            channel_id: channel,
+          };
+
+          break;
+        case 'msteams':
+          integrationAction = {
+            id: IssueAlertActionType.MS_TEAMS,
+            team: integration?.id,
+            channel: channel,
+          };
+          break;
+        default:
+          return undefined;
+      }
+
+      return api.requestPromise(`/projects/${organization.slug}/${projectSlug}/rules/`, {
+        method: 'POST',
+        data: {
+          name,
+          conditions,
+          actions: [integrationAction],
+          actionMatch,
+          frequency,
+        },
+      });
+    },
+    [
+      actions,
+      api,
+      provider,
+      integration,
+      channel,
+      organization.features,
+      organization.slug,
+    ]
+  );
+
+  return {
+    createNotificationAction,
+    notificationProps: {
+      actions,
+      provider,
+      integration,
+      channel,
+      setActions,
+      setProvider,
+      setIntegration,
+      setChannel,
+      providersToIntegrations,
+      refetchConfigs: messagingIntegrationsQuery.refetch,
+      querySuccess: messagingIntegrationsQuery.isSuccess,
+      shouldRenderSetupButton,
+    },
+  };
+}
+
+export default function IssueAlertNotificationOptions(
+  notificationProps: IssueAlertNotificationProps
+) {
+  const {actions, setActions, refetchConfigs, querySuccess, shouldRenderSetupButton} =
+    notificationProps;
+
+  const shouldRenderNotificationConfigs = actions.some(
+    v => v !== MultipleCheckboxOptions.EMAIL
+  );
+
+  if (!querySuccess) {
+    return null;
+  }
+
+  return (
+    <Fragment>
+      <MultipleCheckbox
+        name="notification"
+        value={actions}
+        onChange={values => setActions(values)}
+      >
+        <Wrapper>
+          <MultipleCheckbox.Item value={MultipleCheckboxOptions.EMAIL} disabled>
+            {t('Notify via email')}
+          </MultipleCheckbox.Item>
+          {!shouldRenderSetupButton && (
+            <div>
+              <MultipleCheckbox.Item value={MultipleCheckboxOptions.INTEGRATION}>
+                {t('Notify via integration (Slack, Discord, MS Teams, etc.)')}
+              </MultipleCheckbox.Item>
+              {shouldRenderNotificationConfigs && (
+                <MessagingIntegrationAlertRule {...notificationProps} />
+              )}
+            </div>
+          )}
+        </Wrapper>
+      </MultipleCheckbox>
+      {shouldRenderSetupButton && (
+        <SetupMessagingIntegrationButton
+          refetchConfigs={refetchConfigs}
+          analyticsParams={{
+            view: MessagingIntegrationAnalyticsView.ALERT_RULE_CREATION,
+          }}
+        />
+      )}
+    </Fragment>
+  );
+}
+
+const Wrapper = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(1)};
+`;

+ 71 - 11
static/app/views/projectInstall/issueAlertOptions.spec.tsx

@@ -1,3 +1,5 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
 import {
   MOCK_RESP_INCONSISTENT_INTERVALS,
   MOCK_RESP_INCONSISTENT_PLACEHOLDERS,
@@ -5,18 +7,40 @@ import {
   MOCK_RESP_VERBOSE,
 } from 'sentry-fixture/ruleConditions';
 
-import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 import selectEvent from 'sentry-test/selectEvent';
 
+import type {IssueAlertNotificationProps} from 'sentry/views/projectInstall/issueAlertNotificationOptions';
 import IssueAlertOptions from 'sentry/views/projectInstall/issueAlertOptions';
 
 describe('IssueAlertOptions', function () {
-  const {organization} = initializeOrg();
+  const organization = OrganizationFixture({
+    features: ['messaging-integration-onboarding-project-creation'],
+  });
   const URL = `/projects/${organization.slug}/rule-conditions/`;
+
+  const notificationProps: IssueAlertNotificationProps = {
+    actions: [],
+    channel: 'channel',
+    integration: OrganizationIntegrationsFixture(),
+    provider: 'slack',
+    providersToIntegrations: {},
+    querySuccess: true,
+    shouldRenderSetupButton: false,
+    refetchConfigs: jest.fn(),
+    setActions: jest.fn(),
+    setChannel: jest.fn(),
+    setIntegration: jest.fn(),
+    setProvider: jest.fn(),
+  };
+
   const props = {
     onChange: jest.fn(),
+    organization,
+    notificationProps,
   };
+  const getComponent = () => <IssueAlertOptions {...props} {...notificationProps} />;
+
   beforeEach(() => {
     MockApiClient.addMockResponse({
       url: `/projects/${organization.slug}/rule-conditions/`,
@@ -34,7 +58,7 @@ describe('IssueAlertOptions', function () {
       body: [],
     });
 
-    render(<IssueAlertOptions {...props} />, {organization});
+    render(getComponent(), {organization});
     expect(screen.getAllByRole('radio')).toHaveLength(2);
   });
 
@@ -44,7 +68,7 @@ describe('IssueAlertOptions', function () {
       body: {},
     });
 
-    render(<IssueAlertOptions {...props} />, {organization});
+    render(getComponent(), {organization});
     expect(screen.getAllByRole('radio')).toHaveLength(2);
   });
 
@@ -54,7 +78,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_INCONSISTENT_INTERVALS,
     });
 
-    render(<IssueAlertOptions {...props} />, {organization});
+    render(getComponent(), {organization});
     expect(screen.getAllByRole('radio')).toHaveLength(2);
   });
 
@@ -63,7 +87,7 @@ describe('IssueAlertOptions', function () {
       url: URL,
       body: MOCK_RESP_INCONSISTENT_PLACEHOLDERS,
     });
-    render(<IssueAlertOptions {...props} />, {organization});
+    render(getComponent(), {organization});
     expect(screen.getAllByRole('radio')).toHaveLength(3);
   });
 
@@ -73,7 +97,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_ONLY_IGNORED_CONDITIONS_INVALID,
     });
 
-    render(<IssueAlertOptions {...props} />, {organization});
+    render(getComponent(), {organization});
     expect(screen.getAllByRole('radio')).toHaveLength(3);
     await selectEvent.select(screen.getByText('Select...'), 'users affected by');
     expect(props.onChange).toHaveBeenCalledWith(
@@ -90,7 +114,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_VERBOSE,
     });
 
-    render(<IssueAlertOptions {...props} />);
+    render(getComponent());
     expect(screen.getAllByRole('radio')).toHaveLength(3);
   });
 
@@ -100,7 +124,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_VERBOSE,
     });
 
-    render(<IssueAlertOptions {...props} />);
+    render(getComponent());
     await selectEvent.select(screen.getByText('occurrences of'), 'users affected by');
     await selectEvent.select(screen.getByText('one minute'), '30 days');
     expect(props.onChange).toHaveBeenCalledWith(
@@ -117,7 +141,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_VERBOSE,
     });
 
-    render(<IssueAlertOptions {...props} />);
+    render(getComponent());
     expect(screen.getByTestId('range-input')).toHaveValue(10);
   });
 
@@ -127,7 +151,7 @@ describe('IssueAlertOptions', function () {
       body: MOCK_RESP_VERBOSE,
     });
 
-    render(<IssueAlertOptions {...props} organization={organization} />);
+    render(getComponent());
     await userEvent.click(screen.getByLabelText(/When there are more than/i));
     expect(props.onChange).toHaveBeenCalledWith(
       expect.objectContaining({
@@ -141,4 +165,40 @@ describe('IssueAlertOptions', function () {
       })
     );
   });
+
+  it('should render alert configuration if `Default` or `Custom` alerts are selected', async () => {
+    MockApiClient.addMockResponse({
+      url: URL,
+      body: MOCK_RESP_VERBOSE,
+    });
+
+    render(getComponent());
+    await screen.findByRole('checkbox', {name: 'Notify via email'});
+    await screen.findByRole('checkbox', {
+      name: 'Notify via integration (Slack, Discord, MS Teams, etc.)',
+    });
+    await selectEvent.select(screen.getByText('occurrences of'), 'users affected by');
+    await screen.findByRole('checkbox', {name: 'Notify via email'});
+    await screen.findByRole('checkbox', {
+      name: 'Notify via integration (Slack, Discord, MS Teams, etc.)',
+    });
+  });
+
+  it('should not render notification configuration if `Create Alerts Later` is selected', async () => {
+    MockApiClient.addMockResponse({
+      url: URL,
+      body: MOCK_RESP_VERBOSE,
+    });
+
+    render(getComponent());
+    await userEvent.click(screen.getByLabelText("I'll create my own alerts later"));
+    expect(
+      screen.queryByRole('checkbox', {name: 'Notify via email'})
+    ).not.toBeInTheDocument();
+    expect(
+      screen.queryByRole('checkbox', {
+        name: 'Notify via integration (Slack, Discord, MS Teams, etc.)',
+      })
+    ).not.toBeInTheDocument();
+  });
 });

+ 24 - 4
static/app/views/projectInstall/issueAlertOptions.tsx

@@ -13,6 +13,9 @@ import type {IssueAlertRuleAction} from 'sentry/types/alerts';
 import {IssueAlertActionType, IssueAlertConditionType} from 'sentry/types/alerts';
 import type {Organization} from 'sentry/types/organization';
 import withOrganization from 'sentry/utils/withOrganization';
+import IssueAlertNotificationOptions, {
+  type IssueAlertNotificationProps,
+} from 'sentry/views/projectInstall/issueAlertNotificationOptions';
 
 export enum MetricValues {
   ERRORS = 0,
@@ -46,6 +49,7 @@ type Props = DeprecatedAsyncComponent['props'] & {
   alertSetting?: string;
   interval?: string;
   metric?: MetricValues;
+  notificationProps?: IssueAlertNotificationProps;
   platformLanguage?: SupportedLanguages;
   threshold?: string;
 };
@@ -69,6 +73,7 @@ type RequestDataFragment = {
   frequency: number;
   name: string;
   shouldCreateCustomRule: boolean;
+  shouldCreateRule: boolean;
 };
 
 function getConditionFrom(
@@ -192,27 +197,32 @@ class IssueAlertOptions extends DeprecatedAsyncComponent<Props, State> {
 
   getUpdatedData(): RequestDataFragment {
     let defaultRules: boolean;
+    let shouldCreateRule: boolean;
     let shouldCreateCustomRule: boolean;
     const alertSetting: RuleAction = parseInt(this.state.alertSetting, 10);
     switch (alertSetting) {
       case RuleAction.DEFAULT_ALERT:
         defaultRules = true;
-        shouldCreateCustomRule = false;
-        break;
-      case RuleAction.CREATE_ALERT_LATER:
-        defaultRules = false;
+        shouldCreateRule = true;
         shouldCreateCustomRule = false;
         break;
       case RuleAction.CUSTOMIZED_ALERTS:
         defaultRules = false;
+        shouldCreateRule = true;
         shouldCreateCustomRule = true;
         break;
+      case RuleAction.CREATE_ALERT_LATER:
+        defaultRules = false;
+        shouldCreateRule = false;
+        shouldCreateCustomRule = false;
+        break;
       default:
         throw new RangeError('Supplied alert creation action is not handled');
     }
 
     return {
       defaultRules,
+      shouldCreateRule,
       shouldCreateCustomRule,
       name: 'Send a notification for new issues',
       conditions:
@@ -303,6 +313,13 @@ class IssueAlertOptions extends DeprecatedAsyncComponent<Props, State> {
           onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
           value={this.state.alertSetting}
         />
+        {this.props.organization.features.includes(
+          'messaging-integration-onboarding-project-creation'
+        ) &&
+          this.props.notificationProps &&
+          parseInt(this.state.alertSetting, 10) !== RuleAction.CREATE_ALERT_LATER && (
+            <IssueAlertNotificationOptions {...this.props.notificationProps} />
+          )}
       </Content>
     );
   }
@@ -313,6 +330,9 @@ export default withOrganization(IssueAlertOptions);
 const Content = styled('div')`
   padding-top: ${space(2)};
   padding-bottom: ${space(4)};
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
 `;
 
 const CustomizeAlert = styled('div')`

+ 99 - 0
static/app/views/projectInstall/messagingIntegrationAlertRule.spec.tsx

@@ -0,0 +1,99 @@
+import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+import selectEvent from 'sentry-test/selectEvent';
+
+import MessagingIntegrationAlertRule from 'sentry/views/projectInstall/messagingIntegrationAlertRule';
+
+describe('MessagingIntegrationAlertRule', function () {
+  const slackIntegrations = [
+    OrganizationIntegrationsFixture({
+      name: "Moo Deng's Workspace",
+    }),
+    OrganizationIntegrationsFixture({
+      name: "Moo Waan's Workspace",
+    }),
+  ];
+  const discordIntegrations = [
+    OrganizationIntegrationsFixture({
+      name: "Moo Deng's Server",
+    }),
+  ];
+  const msteamsIntegrations = [
+    OrganizationIntegrationsFixture({
+      name: "Moo Deng's Team",
+    }),
+  ];
+
+  const providersToIntegrations = {
+    slack: slackIntegrations,
+    discord: discordIntegrations,
+    msteams: msteamsIntegrations,
+  };
+
+  const mockSetChannel = jest.fn();
+  const mockSetIntegration = jest.fn();
+  const mockSetProvider = jest.fn();
+
+  const notificationProps = {
+    actions: [],
+    channel: 'channel',
+    integration: slackIntegrations[0],
+    provider: 'slack',
+    providersToIntegrations: providersToIntegrations,
+    querySuccess: true,
+    shouldRenderSetupButton: false,
+    refetchConfigs: jest.fn(),
+    setActions: jest.fn(),
+    setChannel: mockSetChannel,
+    setIntegration: mockSetIntegration,
+    setProvider: mockSetProvider,
+  };
+
+  const getComponent = () => <MessagingIntegrationAlertRule {...notificationProps} />;
+
+  it('renders', function () {
+    render(getComponent());
+    expect(screen.getAllByRole('textbox')).toHaveLength(3);
+  });
+
+  it('calls setter when new integration is selected', async function () {
+    render(getComponent());
+    await selectEvent.select(
+      screen.getByText("Moo Deng's Workspace"),
+      "Moo Waan's Workspace"
+    );
+    expect(mockSetIntegration).toHaveBeenCalled();
+  });
+
+  it('calls setters when new provider is selected', async function () {
+    render(getComponent());
+    await selectEvent.select(screen.getByText('Slack'), 'Discord');
+    expect(mockSetProvider).toHaveBeenCalled();
+    expect(mockSetIntegration).toHaveBeenCalled();
+    expect(mockSetChannel).toHaveBeenCalled();
+  });
+
+  it('disables provider select when there is only one provider option', function () {
+    render(
+      <MessagingIntegrationAlertRule
+        {...notificationProps}
+        providersToIntegrations={{slack: slackIntegrations}}
+      />
+    );
+    expect(screen.getByLabelText('provider')).toBeDisabled();
+  });
+
+  it('disables integration select when there is only one integration option', function () {
+    render(
+      <MessagingIntegrationAlertRule
+        {...{
+          ...notificationProps,
+          integration: discordIntegrations[0],
+          provider: 'discord',
+        }}
+      />
+    );
+    expect(screen.getByLabelText('integration')).toBeDisabled();
+  });
+});

Some files were not shown because too many files changed in this diff