diff --git a/static/app/components/modals/projectCreationModal.spec.tsx b/static/app/components/modals/projectCreationModal.spec.tsx index 8a330464c47b2b..11ea0fc5544c4c 100644 --- a/static/app/components/modals/projectCreationModal.spec.tsx +++ b/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]); diff --git a/static/app/utils/analytics/workflowAnalyticsEvents.tsx b/static/app/utils/analytics/workflowAnalyticsEvents.tsx index a59046f069fb6b..0545e7be22ac45 100644 --- a/static/app/utils/analytics/workflowAnalyticsEvents.tsx +++ b/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': {}; diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx index 6bddf4efe8a0dd..a51d0f28d6fea4 100644 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ b/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', @@ -367,7 +377,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 +387,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 +427,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(); }); diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index e2b307b3f72fea..3fe0324d287890 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/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,18 @@ function CreateProject() { const isMissingProjectName = projectName === ''; const isMissingAlertThreshold = shouldCreateCustomRule && !conditions?.every?.(condition => condition.value); + const isMissingMessagingIntegrationChannel = + 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 +318,16 @@ 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'), + }; + const alertFrequencyDefaultValues = useMemo(() => { if (!autoFill) { return {}; @@ -348,6 +389,7 @@ function CreateProject() { {...alertFrequencyDefaultValues} platformLanguage={platform?.language as SupportedLanguages} onChange={updatedData => setAlertRuleConfig(updatedData)} + notificationProps={notificationProps} /> {t('Name your project and assign it a team')} {Object.keys(errors).map(key => (
- {startCase(key)}: {errors[key]} + + {keyToErrorText[key] ? keyToErrorText[key] : startCase(key)} + + : {errors[key]}
))} diff --git a/static/app/views/projectInstall/issueAlertNotificationOptions.spec.tsx b/static/app/views/projectInstall/issueAlertNotificationOptions.spec.tsx new file mode 100644 index 00000000000000..28c6256aae3e87 --- /dev/null +++ b/static/app/views/projectInstall/issueAlertNotificationOptions.spec.tsx @@ -0,0 +1,102 @@ +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 from 'sentry/views/projectInstall/issueAlertNotificationOptions'; + +describe('MessagingIntegrationAlertRule', function () { + const organization = OrganizationFixture({ + features: ['messaging-integration-onboarding-project-creation'], + }); + let mockResponse: jest.Mock; + let integrations: OrganizationIntegration[] = []; + const mockSetAction = jest.fn(); + + const notificationProps = { + actions: [], + channel: 'channel', + integration: undefined, + provider: 'slack', + setActions: mockSetAction, + setChannel: jest.fn(), + setIntegration: jest.fn(), + setProvider: jest.fn(), + }; + + const getComponent = () => ; + + beforeEach(function () { + integrations = [ + OrganizationIntegrationsFixture({ + name: "Moo Deng's Workspace", + status: 'disabled', + }), + OrganizationIntegrationsFixture({ + name: "Moo Waan's Workspace", + status: 'disabled', + }), + ]; + mockResponse = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`, + body: integrations, + }); + }); + + afterEach(function () { + MockApiClient.clearMockResponses(); + }); + + 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[] = []; + providerKeys.forEach(providerKey => { + mockResponses.push( + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/config/integrations/?provider_key=${providerKey}`, + body: {providers: providers(providerKey)}, + }) + ); + }); + render(getComponent(), {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}); + expect(mockResponse).toHaveBeenCalled(); + 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); + expect(mockResponse).toHaveBeenCalled(); + }); + + 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(); + }); +}); diff --git a/static/app/views/projectInstall/issueAlertNotificationOptions.tsx b/static/app/views/projectInstall/issueAlertNotificationOptions.tsx new file mode 100644 index 00000000000000..11a0940ffb4cac --- /dev/null +++ b/static/app/views/projectInstall/issueAlertNotificationOptions.tsx @@ -0,0 +1,277 @@ +import {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 {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: 'Discord', + action: IssueAlertActionType.DISCORD, + placeholder: '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: 'MSTeams', + action: IssueAlertActionType.MS_TEAMS, + placeholder: '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; + setActions: (action: MultipleCheckboxOptions[]) => void; + setChannel: (channel: string | undefined) => void; + setIntegration: (integration: OrganizationIntegration | undefined) => void; + setProvider: (provider: string | undefined) => void; +}; + +export function useCreateNotificationAction() { + const [actions, setActions] = useState([ + MultipleCheckboxOptions.EMAIL, + ]); + const [provider, setProvider] = useState(undefined); + const [integration, setIntegration] = useState( + undefined + ); + const [channel, setChannel] = useState(undefined); + + const api = useApi(); + const organization = useOrganization(); + + 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; + 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, + }, + }; +} + +export default function IssueAlertNotificationOptions( + notificationProps: IssueAlertNotificationProps +) { + const organization = useOrganization(); + const {actions, provider, setActions, setIntegration, setProvider} = notificationProps; + + const messagingIntegrationsQuery = useApiQuery( + [`/organizations/${organization.slug}/integrations/?integrationType=messaging`], + {staleTime: Infinity} + ); + + const providersToIntegrations = useMemo(() => { + const map: {[key: string]: OrganizationIntegration[]} = {}; + if (messagingIntegrationsQuery.data) { + for (const i of messagingIntegrationsQuery.data) { + const providerSlug = i.provider.slug; + map[providerSlug] = map[providerSlug] ?? []; + map[providerSlug].push(i); + } + } + return map; + }, [messagingIntegrationsQuery.data]); + + useEffect(() => { + const providerKeys = Object.keys(providersToIntegrations); + if (providerKeys.length > 0) { + const firstProvider = providerKeys[0]; + setProvider(firstProvider); + + const firstIntegration = providersToIntegrations[firstProvider][0]; + setIntegration(firstIntegration); + } + }, [providersToIntegrations, setProvider, setIntegration]); + + const shouldRenderSetupButton = useMemo(() => { + return messagingIntegrationsQuery.data?.every(i => i.status !== 'active'); + }, [messagingIntegrationsQuery.data]); + + const shouldRenderNotificationConfigs = useMemo(() => { + return actions.some(v => v !== MultipleCheckboxOptions.EMAIL); + }, [actions]); + + if (messagingIntegrationsQuery.isLoading || messagingIntegrationsQuery.isError) { + return null; + } + + return ( +
+ setActions(values)} + > + + + Notify via email + + {!shouldRenderSetupButton && provider && ( +
+ + Notify via integration (Slack, Discord, MS Teams, etc.) + + {shouldRenderNotificationConfigs && ( + + )} +
+ )} +
+
+ {shouldRenderSetupButton && ( + + )} +
+ ); +} + +const Wrapper = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1)}; +`; diff --git a/static/app/views/projectInstall/issueAlertOptions.spec.tsx b/static/app/views/projectInstall/issueAlertOptions.spec.tsx index c7c8f3a8f779f3..ae1076776ca687 100644 --- a/static/app/views/projectInstall/issueAlertOptions.spec.tsx +++ b/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,23 +7,50 @@ 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 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 props = { onChange: jest.fn(), }; + + const notificationProps = { + alertNotificationAction: [], + alertNotificationChannel: 'channel', + alertNotificationIntegration: OrganizationIntegrationsFixture({ + name: "Moo Deng's Workspace", + status: 'disabled', + }), + alertNotificationProvider: 'slack', + setAlertNotificationAction: jest.fn(), + setAlertNotificationChannel: jest.fn(), + setAlertNotificationIntegration: jest.fn(), + setAlertNotificationProvider: jest.fn(), + }; + + const getComponent = () => ; + beforeEach(() => { MockApiClient.addMockResponse({ 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(() => { MockApiClient.clearMockResponses(); @@ -34,7 +63,7 @@ describe('IssueAlertOptions', function () { body: [], }); - render(, {organization}); + render(getComponent(), {organization}); expect(screen.getAllByRole('radio')).toHaveLength(2); }); @@ -44,7 +73,7 @@ describe('IssueAlertOptions', function () { body: {}, }); - render(, {organization}); + render(getComponent(), {organization}); expect(screen.getAllByRole('radio')).toHaveLength(2); }); @@ -54,7 +83,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_INCONSISTENT_INTERVALS, }); - render(, {organization}); + render(getComponent(), {organization}); expect(screen.getAllByRole('radio')).toHaveLength(2); }); @@ -63,7 +92,7 @@ describe('IssueAlertOptions', function () { url: URL, body: MOCK_RESP_INCONSISTENT_PLACEHOLDERS, }); - render(, {organization}); + render(getComponent(), {organization}); expect(screen.getAllByRole('radio')).toHaveLength(3); }); @@ -73,7 +102,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_ONLY_IGNORED_CONDITIONS_INVALID, }); - render(, {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 +119,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_VERBOSE, }); - render(); + render(getComponent()); expect(screen.getAllByRole('radio')).toHaveLength(3); }); @@ -100,7 +129,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_VERBOSE, }); - render(); + 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 +146,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_VERBOSE, }); - render(); + render(getComponent()); expect(screen.getByTestId('range-input')).toHaveValue(10); }); @@ -127,7 +156,7 @@ describe('IssueAlertOptions', function () { body: MOCK_RESP_VERBOSE, }); - render(); + render(getComponent()); await userEvent.click(screen.getByLabelText(/When there are more than/i)); expect(props.onChange).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/static/app/views/projectInstall/issueAlertOptions.tsx b/static/app/views/projectInstall/issueAlertOptions.tsx index f016865bc00e82..24ec10362b2933 100644 --- a/static/app/views/projectInstall/issueAlertOptions.tsx +++ b/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 { 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 { 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 && ( + + )} ); } @@ -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')` diff --git a/static/app/views/projectInstall/messagingIntegrationAlertRule.spec.tsx b/static/app/views/projectInstall/messagingIntegrationAlertRule.spec.tsx new file mode 100644 index 00000000000000..a22375a5184563 --- /dev/null +++ b/static/app/views/projectInstall/messagingIntegrationAlertRule.spec.tsx @@ -0,0 +1,101 @@ +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 mockSetChannel = jest.fn(); + const mockSetIntegration = jest.fn(); + const mockSetProvider = jest.fn(); + + const notificationProps = { + actions: [], + channel: 'channel', + integration: slackIntegrations[0], + provider: 'slack', + setActions: jest.fn(), + setChannel: mockSetChannel, + setIntegration: mockSetIntegration, + setProvider: mockSetProvider, + }; + + const providersToIntegrations = { + slack: slackIntegrations, + discord: discordIntegrations, + msteams: msteamsIntegrations, + }; + + const getComponent = () => ( + + ); + + 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( + + ); + expect(screen.getByLabelText('provider')).toBeDisabled(); + }); + + it('disables integration select when there is only one integration option', function () { + render( + + ); + expect(screen.getByLabelText('integration')).toBeDisabled(); + }); +}); diff --git a/static/app/views/projectInstall/messagingIntegrationAlertRule.tsx b/static/app/views/projectInstall/messagingIntegrationAlertRule.tsx new file mode 100644 index 00000000000000..fc132e9e9ec16d --- /dev/null +++ b/static/app/views/projectInstall/messagingIntegrationAlertRule.tsx @@ -0,0 +1,122 @@ +import {useEffect, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import SelectControl from 'sentry/components/forms/controls/selectControl'; +import Input from 'sentry/components/input'; +// import {t, tct} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import { + type IssueAlertNotificationProps, + providerDetails, +} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; + +type Props = { + notificationProps: IssueAlertNotificationProps; + providersToIntegrations: Record; +}; + +export default function MessagingIntegrationAlertRule({ + notificationProps, + providersToIntegrations, +}: Props) { + const {channel, integration, provider, setChannel, setIntegration, setProvider} = + notificationProps; + + const providerOptions = useMemo( + () => + Object.keys(providersToIntegrations).map(p => ({ + value: p, + label: providerDetails[p].name, + })), + [providersToIntegrations] + ); + const integrationOptions = useMemo( + () => + provider && providersToIntegrations[provider] + ? providersToIntegrations[provider]?.map(i => ({ + value: i, + label: i.name, + })) + : [], + [providersToIntegrations, provider] + ); + + useEffect(() => { + const providerKeys = Object.keys(providersToIntegrations); + if (providerKeys.length > 0) { + const firstProvider = providerKeys[0]; + setProvider(firstProvider); + + const firstIntegration = providersToIntegrations[firstProvider][0]; + setIntegration(firstIntegration); + } + }, [providersToIntegrations, setProvider, setIntegration]); + + if (!provider) { + return null; + } + + return ( + + + {providerDetails[provider]?.makeSentence({ + providerName: ( + { + setProvider(p.value); + setIntegration(providersToIntegrations[p.value][0]); + setChannel(''); + }} + /> + ), + integrationName: ( + setIntegration(i.value)} + /> + ), + target: ( + ) => + setChannel(e.target.value) + } + /> + ), + })} + + + ); +} + +const RuleWrapper = styled('div')` + padding: ${space(1)}; + background-color: ${p => p.theme.backgroundSecondary}; + border-radius: ${p => p.theme.borderRadius}; +`; + +const Rule = styled('div')` + display: flex; + align-items: center; + gap: ${space(1)}; +`; + +const InlineSelectControl = styled(SelectControl)` + width: 180px; +`; + +const InlineInput = styled(Input)` + width: auto; + min-height: 28px; +`; diff --git a/static/app/views/projectInstall/newProject.spec.tsx b/static/app/views/projectInstall/newProject.spec.tsx index be3c676089cc82..312c8b83939612 100644 --- a/static/app/views/projectInstall/newProject.spec.tsx +++ b/static/app/views/projectInstall/newProject.spec.tsx @@ -1,3 +1,5 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; import {MOCK_RESP_VERBOSE} from 'sentry-fixture/ruleConditions'; import {render} from 'sentry-test/reactTestingLibrary'; @@ -5,11 +7,23 @@ import {render} from 'sentry-test/reactTestingLibrary'; import NewProject from 'sentry/views/projectInstall/newProject'; describe('NewProjectPlatform', function () { + const organization = OrganizationFixture(); + const integrations = [ + OrganizationIntegrationsFixture({ + name: "Moo Deng's Workspace", + status: 'active', + }), + ]; + beforeEach(() => { MockApiClient.addMockResponse({ url: `/projects/org-slug/rule-conditions/`, body: MOCK_RESP_VERBOSE, }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`, + body: integrations, + }); }); afterEach(() => {