Skip to content
Open
114 changes: 52 additions & 62 deletions static/app/views/onboarding/components/scmAlertFrequency.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,73 +31,64 @@ export function ScmAlertFrequency({
const isLaterSelected = alertSetting === RuleAction.CREATE_ALERT_LATER;

return (
<Stack gap="lg">
<Stack gap="md" role="radiogroup" aria-label={t('Alert frequency')}>
<ScmAlertOptionCard
label={t('High priority issues')}
description={t('Alert on new, regressed, and escalating issues')}
isSelected={isDefaultSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.DEFAULT_ALERT)}
/>
<Stack gap="md" role="radiogroup" aria-label={t('Alert frequency')}>
<ScmAlertOptionCard
label={t('High priority issues')}
description={t('Alert on new, regressed, and escalating issues')}
isSelected={isDefaultSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.DEFAULT_ALERT)}
/>

<ScmAlertOptionCard
label={t('Custom threshold')}
isSelected={isCustomSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.CUSTOMIZED_ALERTS)}
>
{isCustomSelected && (
<Stack gap="lg">
<Stack gap="xs">
<Text size="md" density="comfortable">
{t('When there are more than')}
</Text>
<Grid gap="xl" columns={{sm: '1fr', md: '1fr 1fr'}}>
<Input
size="md"
type="number"
min="0"
placeholder="10"
value={threshold}
onChange={e => onFieldChange('threshold', e.target.value)}
/>
<Select
size="md"
value={metric}
options={METRIC_CHOICES}
onChange={option => onFieldChange('metric', option.value)}
menuPortalTarget={document.body}
/>
</Grid>
</Stack>
<Stack gap="xs">
<Text size="md" density="comfortable">
{t('a unique error in')}
</Text>
<ScmAlertOptionCard
label={t('Custom threshold')}
isSelected={isCustomSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.CUSTOMIZED_ALERTS)}
>
{isCustomSelected && (
<Stack gap="lg">
<Stack gap="xs">
<Text size="md" density="comfortable">
{t('When there are more than')}
</Text>
<Grid gap="xl" columns={{sm: '1fr', md: '1fr 1fr'}}>
<Input
size="md"
type="number"
min="0"
placeholder="10"
value={threshold}
onChange={e => onFieldChange('threshold', e.target.value)}
/>
<Select
size="md"
value={interval}
options={INTERVAL_CHOICES}
onChange={option => onFieldChange('interval', option.value)}
value={metric}
options={METRIC_CHOICES}
onChange={option => onFieldChange('metric', option.value)}
menuPortalTarget={document.body}
/>
</Stack>
</Grid>
</Stack>
)}
</ScmAlertOptionCard>

<ScmAlertOptionCard
label={t("I'll set up alerts later")}
isSelected={isLaterSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.CREATE_ALERT_LATER)}
/>
</Stack>
<Stack gap="xs">
<Text size="md" density="comfortable">
{t('a unique error in')}
</Text>
<Select
size="md"
value={interval}
options={INTERVAL_CHOICES}
onChange={option => onFieldChange('interval', option.value)}
menuPortalTarget={document.body}
/>
</Stack>
</Stack>
)}
</ScmAlertOptionCard>

<Flex gap="sm" align="center">
<IconInfo size="md" variant="secondary" />
<Text variant="secondary" size="md" density="comfortable">
{t('You can always change alerts after project creation')}
</Text>
</Flex>
<ScmAlertOptionCard
label={t("I'll set up alerts later")}
isSelected={isLaterSelected}
onSelect={() => onFieldChange('alertSetting', RuleAction.CREATE_ALERT_LATER)}
/>
</Stack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ScmAlertFrequencySection>;

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<Props> = {}) {
const props: Props = {
analyticsFlow: 'project-creation',
alertRuleConfig: DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
notificationProps,
onAlertChange: jest.fn(),
...overrides,
};
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +21,7 @@ import {ScmCollapsibleSection} from './scmCollapsibleSection';
interface ScmAlertFrequencySectionProps {
alertRuleConfig: AlertRuleOptions;
analyticsFlow: ScmAnalyticsFlow;
notificationProps: IssueAlertNotificationProps;
onAlertChange: <K extends keyof AlertRuleOptions>(
key: K,
value: AlertRuleOptions[K]
Expand All @@ -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 : (
<IssueAlertNotificationOptions {...notificationProps} />
);

const footer = (
<Flex gap="sm" align="center">
<IconInfo size="md" variant="secondary" />
<Text variant="secondary" size="md" density="comfortable">
{t('You can always change alerts after project creation')}
</Text>
</Flex>
);

if (collapsible) {
// Summarize the current selection in the collapsed header.
const alertSettingLabel: Record<RuleAction, [string, TagVariant]> = {
Expand Down Expand Up @@ -63,13 +86,17 @@ export function ScmAlertFrequencySection({
</Tag>
}
>
<ScmAlertFrequency {...alertRuleConfig} onFieldChange={onAlertChange} />
<Stack gap="lg">
<ScmAlertFrequency {...alertRuleConfig} onFieldChange={onAlertChange} />
{notificationOptions}
{footer}
</Stack>
</ScmCollapsibleSection>
);
}

return (
<Stack gap="md">
<Stack gap="lg">
<Stack gap="xs">
<Container>
<Text bold size="md" density="comfortable">
Expand All @@ -83,6 +110,8 @@ export function ScmAlertFrequencySection({
</Container>
</Stack>
<ScmAlertFrequency {...alertRuleConfig} onFieldChange={onAlertChange} />
{notificationOptions}
{footer}
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading