diff --git a/static/app/views/onboarding/components/scmAlertFrequency.tsx b/static/app/views/onboarding/components/scmAlertFrequency.tsx index 8f7b04968b02e7..de9744a6369e72 100644 --- a/static/app/views/onboarding/components/scmAlertFrequency.tsx +++ b/static/app/views/onboarding/components/scmAlertFrequency.tsx @@ -1,9 +1,8 @@ import {Input} from '@sentry/scraps/input'; -import {Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {Select} from '@sentry/scraps/select'; import {Text} from '@sentry/scraps/text'; -import {IconInfo} from 'sentry/icons/iconInfo'; import {t} from 'sentry/locale'; import {ScmAlertOptionCard} from 'sentry/views/onboarding/components/scmAlertOptionCard'; import { @@ -32,73 +31,64 @@ export function ScmAlertFrequency({ const isLaterSelected = alertSetting === RuleAction.CREATE_ALERT_LATER; return ( - - - onFieldChange('alertSetting', RuleAction.DEFAULT_ALERT)} - /> + + onFieldChange('alertSetting', RuleAction.DEFAULT_ALERT)} + /> - onFieldChange('alertSetting', RuleAction.CUSTOMIZED_ALERTS)} - > - {isCustomSelected && ( - - - - {t('When there are more than')} - - - onFieldChange('threshold', e.target.value)} - /> - onFieldChange('threshold', e.target.value)} + /> onFieldChange('interval', option.value)} + menuPortalTarget={document.body} + /> + + + )} + - - - - {t('You can always change alerts after project creation')} - - + onFieldChange('alertSetting', RuleAction.CREATE_ALERT_LATER)} + /> ); } diff --git a/static/app/views/onboarding/components/scmAlertFrequencySection.spec.tsx b/static/app/views/onboarding/components/scmAlertFrequencySection.spec.tsx index 5affe14b985cf2..b42a9eb92544ed 100644 --- a/static/app/views/onboarding/components/scmAlertFrequencySection.spec.tsx +++ b/static/app/views/onboarding/components/scmAlertFrequencySection.spec.tsx @@ -2,16 +2,38 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import {DEFAULT_ISSUE_ALERT_OPTIONS_VALUES} from 'sentry/views/projectInstall/issueAlertOptions'; +import { + type IssueAlertNotificationProps, + MultipleCheckboxOptions, +} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; +import { + DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, + RuleAction, +} from 'sentry/views/projectInstall/issueAlertOptions'; import {ScmAlertFrequencySection} from './scmAlertFrequencySection'; type Props = React.ComponentProps; +const notificationProps: IssueAlertNotificationProps = { + actions: [MultipleCheckboxOptions.EMAIL], + provider: undefined, + integration: undefined, + channel: undefined, + providersToIntegrations: {}, + querySuccess: true, + shouldRenderSetupButton: false, + setActions: jest.fn(), + setProvider: jest.fn(), + setIntegration: jest.fn(), + setChannel: jest.fn(), +}; + function renderSection(overrides: Partial = {}) { const props: Props = { analyticsFlow: 'project-creation', alertRuleConfig: DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, + notificationProps, onAlertChange: jest.fn(), ...overrides, }; @@ -43,4 +65,22 @@ describe('ScmAlertFrequencySection', () => { expect(screen.getByText('Alert frequency')).toBeInTheDocument(); expect(screen.getByText('Get notified when things go wrong')).toBeInTheDocument(); }); + + it('shows the notification options when alerts are enabled', () => { + renderSection({analyticsFlow: 'onboarding'}); + + expect(screen.getByText('Notify via email')).toBeInTheDocument(); + }); + + it('hides the notification options when alerts are turned off', () => { + renderSection({ + analyticsFlow: 'onboarding', + alertRuleConfig: { + ...DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, + alertSetting: RuleAction.CREATE_ALERT_LATER, + }, + }); + + expect(screen.queryByText('Notify via email')).not.toBeInTheDocument(); + }); }); diff --git a/static/app/views/onboarding/components/scmAlertFrequencySection.tsx b/static/app/views/onboarding/components/scmAlertFrequencySection.tsx index 2cb15f7b841bc0..ad8b01ec44ccda 100644 --- a/static/app/views/onboarding/components/scmAlertFrequencySection.tsx +++ b/static/app/views/onboarding/components/scmAlertFrequencySection.tsx @@ -1,9 +1,14 @@ import {Tag} from '@sentry/scraps/badge'; -import {Container, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; +import {IconInfo} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {TagVariant} from 'sentry/utils/theme'; +import { + IssueAlertNotificationOptions, + type IssueAlertNotificationProps, +} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; import { type AlertRuleOptions, RuleAction, @@ -16,6 +21,7 @@ import {ScmCollapsibleSection} from './scmCollapsibleSection'; interface ScmAlertFrequencySectionProps { alertRuleConfig: AlertRuleOptions; analyticsFlow: ScmAnalyticsFlow; + notificationProps: IssueAlertNotificationProps; onAlertChange: ( key: K, value: AlertRuleOptions[K] @@ -32,10 +38,27 @@ interface ScmAlertFrequencySectionProps { export function ScmAlertFrequencySection({ alertRuleConfig, analyticsFlow, + notificationProps, onAlertChange, }: ScmAlertFrequencySectionProps) { const collapsible = analyticsFlow === 'project-creation'; + // Notification options are irrelevant when the user opts out of alerts, so + // hide them for "create alerts later" (mirrors issueAlertOptions). + const notificationOptions = + alertRuleConfig.alertSetting === RuleAction.CREATE_ALERT_LATER ? null : ( + + ); + + const footer = ( + + + + {t('You can always change alerts after project creation')} + + + ); + if (collapsible) { // Summarize the current selection in the collapsed header. const alertSettingLabel: Record = { @@ -63,13 +86,17 @@ export function ScmAlertFrequencySection({ } > - + + + {notificationOptions} + {footer} + ); } return ( - + @@ -83,6 +110,8 @@ export function ScmAlertFrequencySection({ + {notificationOptions} + {footer} ); } diff --git a/static/app/views/onboarding/components/useScmProjectDetails.spec.tsx b/static/app/views/onboarding/components/useScmProjectDetails.spec.tsx index d366075bace577..a8c7acd55dde0b 100644 --- a/static/app/views/onboarding/components/useScmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/components/useScmProjectDetails.spec.tsx @@ -1,11 +1,12 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {TeamFixture} from 'sentry-fixture/team'; -import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; +import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import {MultipleCheckboxOptions} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; import {useScmProjectDetails} from './useScmProjectDetails'; @@ -41,8 +42,44 @@ describe('useScmProjectDetails', () => { ); } + beforeEach(() => { + // useCreateNotificationAction queries messaging integrations on mount. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/`, + body: [], + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + }); + }); + afterEach(() => { TeamStore.reset(); + MockApiClient.clearMockResponses(); + }); + + it('requires an integration channel when notifying via integration', () => { + TeamStore.loadInitialData([adminTeam]); + ProjectsStore.loadInitialData([]); + + const {result} = renderDetails(); + + // Default actions are email-only, so no channel is required. + expect(result.current.missingFields.notificationChannel).toBe(false); + + // Selecting the integration action with no channel blocks submission. + act(() => { + result.current.notificationProps.setActions([ + MultipleCheckboxOptions.EMAIL, + MultipleCheckboxOptions.INTEGRATION, + ]); + }); + expect(result.current.missingFields.notificationChannel).toBe(true); + expect(result.current.canSubmit).toBe(false); + + // Picking a channel clears the requirement. + act(() => { + result.current.notificationProps.setChannel({label: '#general', value: '#general'}); + }); + expect(result.current.missingFields.notificationChannel).toBe(false); }); it('does not report the team as missing while teams are still loading', () => { diff --git a/static/app/views/onboarding/components/useScmProjectDetails.ts b/static/app/views/onboarding/components/useScmProjectDetails.ts index a84bc5c454a3c1..f76c8ae463651b 100644 --- a/static/app/views/onboarding/components/useScmProjectDetails.ts +++ b/static/app/views/onboarding/components/useScmProjectDetails.ts @@ -17,6 +17,11 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; import {useTeams} from 'sentry/utils/useTeams'; import type {ScmAnalyticsFlow} from 'sentry/views/onboarding/components/scmAnalyticsFlow'; +import { + type IssueAlertNotificationProps, + MultipleCheckboxOptions, + useCreateNotificationAction, +} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; import { DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, getRequestDataFragment, @@ -99,7 +104,14 @@ interface ScmProjectDetailsForm { /** Whether the team selector should be hidden (no-access member). */ isOrgMemberWithNoAccess: boolean; /** Required fields still missing, for disabled-submit messaging. */ - missingFields: {platform: boolean; projectName: boolean; team: boolean}; + missingFields: { + notificationChannel: boolean; + platform: boolean; + projectName: boolean; + team: boolean; + }; + /** Messaging-integration notification picker props for the alert section. */ + notificationProps: IssueAlertNotificationProps; onAlertChange: ( key: K, value: AlertRuleOptions[K] @@ -138,6 +150,10 @@ export function useScmProjectDetails({ const {teams, fetching: isLoadingTeams} = useTeams(); const {projects, initiallyLoaded: projectsLoaded} = useProjects(); const createProjectAndRules = useCreateProjectAndRules(); + // Provides the messaging-integration notification picker (notificationProps, + // rendered in ScmAlertFrequencySection) and the side-effect that creates the + // chosen notification rule at project creation. + const {createNotificationAction, notificationProps} = useCreateNotificationAction(); const accessTeams = teams.filter((team: Team) => team.access.includes('team:admin')); const firstAdminTeam = accessTeams[0]; @@ -213,7 +229,17 @@ export function useScmProjectDetails({ ] ); + // When notifying via a messaging integration, a channel must be picked before + // the project can be created. Mirrors the classic flow's active gate (its + // useValidateChannel is wired with enabled:false, so live validation is off + // there too; this is the real check). Irrelevant when alerts are turned off. + const isMissingNotificationChannel = + alertRuleConfig.alertSetting !== RuleAction.CREATE_ALERT_LATER && + notificationProps.actions.includes(MultipleCheckboxOptions.INTEGRATION) && + !notificationProps.channel; + const missingFields = { + notificationChannel: isMissingNotificationChannel, platform: !selectedPlatform, projectName: projectNameResolved.length === 0, // While teams load, teamSlugResolved is empty only because firstAdminTeam @@ -239,6 +265,7 @@ export function useScmProjectDetails({ !missingFields.projectName && !missingFields.team && !missingFields.platform && + !missingFields.notificationChannel && !isCompleting && !isLoadingTeams && projectsLoaded; @@ -261,7 +288,13 @@ export function useScmProjectDetails({ alertRuleConfig.alertSetting === savedAlert?.alertSetting && alertRuleConfig.interval === savedAlert?.interval && alertRuleConfig.metric === savedAlert?.metric && - alertRuleConfig.threshold === savedAlert?.threshold; + alertRuleConfig.threshold === savedAlert?.threshold && + // A configured messaging-integration notification would create a rule the + // reused project lacks: the selection isn't persisted across nav, so the + // baseline is always "no integration". Treat it as a change so the reuse + // shortcut can't silently drop the notification rule. Persisting the + // selection (and comparing it precisely) is tracked as follow-up work. + !notificationProps.actions.includes(MultipleCheckboxOptions.INTEGRATION); const submit = useCallback(async () => { if (!selectedPlatform || !canSubmit || isCompletingRef.current) { @@ -296,7 +329,7 @@ export function useScmProjectDetails({ platform: selectedPlatform, team: isOrgMemberWithNoAccess ? undefined : teamSlugResolved, alertRuleConfig: getRequestDataFragment(alertRuleConfig), - createNotificationAction: () => {}, + createNotificationAction, }); if (selectedRepository?.id) { @@ -329,6 +362,7 @@ export function useScmProjectDetails({ analyticsFlow, alertRuleConfig, canSubmit, + createNotificationAction, createProjectAndRules, existingProject, isOrgMemberWithNoAccess, @@ -349,6 +383,7 @@ export function useScmProjectDetails({ onTeamChange, alertRuleConfig, onAlertChange, + notificationProps, isOrgMemberWithNoAccess, missingFields, canSubmit, diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index 7aae048d733447..a5c9c776193fd8 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -95,6 +95,7 @@ export function ScmProjectDetails({ diff --git a/static/app/views/projectInstall/scmCreateProject.tsx b/static/app/views/projectInstall/scmCreateProject.tsx index 92df68b74825ae..ae3e176774f727 100644 --- a/static/app/views/projectInstall/scmCreateProject.tsx +++ b/static/app/views/projectInstall/scmCreateProject.tsx @@ -73,12 +73,16 @@ function getSubmitTooltipText({ platform, projectName, team, + notificationChannel, }: { + notificationChannel: boolean; platform: boolean; projectName: boolean; team: boolean; }): string | undefined { - const missingCount = [platform, projectName, team].filter(Boolean).length; + const missingCount = [platform, projectName, team, notificationChannel].filter( + Boolean + ).length; if (missingCount > 1) { return t('Please fill out all the required fields'); } @@ -91,6 +95,9 @@ function getSubmitTooltipText({ if (team) { return t('Please select a team'); } + if (notificationChannel) { + return t('Please provide an integration channel for alert notifications'); + } return undefined; } @@ -368,6 +375,7 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) {