From 1b70be3c6dbcdf69e5d163b3e884f0eef6f5f2f1 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 31 Jul 2026 13:26:15 -0500 Subject: [PATCH 1/3] feat(onboarding): Add SCM messaging treatment route Add the five-step treatment route, defer project creation, and persist a typed messaging destination in the onboarding session. Refs VDY-140 --- .../onboarding/onboardingContext.spec.tsx | 40 ++++ .../onboarding/onboardingContext.tsx | 20 +- .../onboarding/scm/scmMessagingSetup.ts | 16 ++ .../components/onboardingSkipButton.spec.tsx | 5 + .../components/onboardingSkipButton.tsx | 5 + .../app/views/onboarding/onboarding.spec.tsx | 109 +++++++++ static/app/views/onboarding/onboarding.tsx | 95 +++++++- .../views/onboarding/scmMessaging.spec.tsx | 110 +++++++++ static/app/views/onboarding/scmMessaging.tsx | 208 ++++++++++++++++++ .../onboarding/scmPlatformFeatures.spec.tsx | 26 +++ .../views/onboarding/scmPlatformFeatures.tsx | 14 +- static/app/views/onboarding/types.ts | 1 + 12 files changed, 633 insertions(+), 16 deletions(-) create mode 100644 static/app/components/onboarding/scm/scmMessagingSetup.ts create mode 100644 static/app/views/onboarding/scmMessaging.spec.tsx create mode 100644 static/app/views/onboarding/scmMessaging.tsx diff --git a/static/app/components/onboarding/onboardingContext.spec.tsx b/static/app/components/onboarding/onboardingContext.spec.tsx index 37bcc79317a1..eea81c227e64 100644 --- a/static/app/components/onboarding/onboardingContext.spec.tsx +++ b/static/app/components/onboarding/onboardingContext.spec.tsx @@ -19,6 +19,7 @@ const platform = { function StateConsumer() { const { + messagingSetup, selectedRepository, selectedPlatform, selectedFeatures, @@ -32,6 +33,7 @@ function StateConsumer() {
{selectedFeatures ? `features:${selectedFeatures.length}` : 'no-features'}
+
{`messaging:${messagingSetup.mode}`}
@@ -62,6 +64,7 @@ describe('OnboardingContextProvider', () => { expect(await screen.findByText('no-repo')).toBeInTheDocument(); expect(screen.getByText('no-platform')).toBeInTheDocument(); expect(screen.getByText('no-features')).toBeInTheDocument(); + expect(screen.getByText('messaging:unconfigured')).toBeInTheDocument(); }); it('keeps a resolved repository and its derived state on load', () => { @@ -71,6 +74,12 @@ describe('OnboardingContextProvider', () => { selectedRepository: RepositoryFixture({id: '42'}), selectedPlatform: platform, selectedFeatures: [ProductSolution.ERROR_MONITORING], + messagingSetup: { + mode: 'selected', + providerKey: 'slack', + integrationId: '15', + channelId: 'C123', + }, }} > @@ -80,6 +89,37 @@ describe('OnboardingContextProvider', () => { expect(screen.getByText('repo:42')).toBeInTheDocument(); expect(screen.getByText('platform:javascript-nextjs')).toBeInTheDocument(); expect(screen.getByText('features:1')).toBeInTheDocument(); + expect(screen.getByText('messaging:selected')).toBeInTheDocument(); + }); + + it('restores messaging setup from session storage after a remount', () => { + sessionStorage.setItem( + 'onboarding', + JSON.stringify({ + messagingSetup: { + mode: 'selected', + providerKey: 'discord', + integrationId: '20', + channelId: '123456789', + }, + }) + ); + + const firstRender = render( + + + + ); + expect(screen.getByText('messaging:selected')).toBeInTheDocument(); + + firstRender.unmount(); + render( + + + + ); + + expect(screen.getByText('messaging:selected')).toBeInTheDocument(); }); }); diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 3db34c4b6caf..cf48dc6689b2 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -1,14 +1,20 @@ import {createContext, useContext, useEffect, useMemo, useRef} from 'react'; import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import { + type ScmMessagingSetup, + UNCONFIGURED_SCM_MESSAGING_SETUP, +} from 'sentry/components/onboarding/scm/scmMessagingSetup'; import type {Integration, Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; type OnboardingContextProps = { clearDerivedState: () => void; + messagingSetup: ScmMessagingSetup; resetOnboarding: () => void; setCreatedProjectSlug: (slug?: string) => void; + setMessagingSetup: (messagingSetup: ScmMessagingSetup) => void; setSelectedFeatures: (features?: ProductSolution[]) => void; setSelectedIntegration: (integration?: Integration) => void; setSelectedPlatform: (selectedSDK?: OnboardingSelectedSDK) => void; @@ -22,6 +28,7 @@ type OnboardingContextProps = { type OnboardingSessionState = { createdProjectSlug?: string; + messagingSetup?: ScmMessagingSetup; selectedFeatures?: ProductSolution[]; selectedIntegration?: Integration; selectedPlatform?: OnboardingSelectedSDK; @@ -42,6 +49,8 @@ const OnboardingContext = createContext({ setSelectedFeatures: () => {}, createdProjectSlug: undefined, setCreatedProjectSlug: () => {}, + messagingSetup: UNCONFIGURED_SCM_MESSAGING_SETUP, + setMessagingSetup: () => {}, clearDerivedState: () => {}, resetOnboarding: () => {}, }); @@ -105,6 +114,10 @@ export function OnboardingContextProvider({children, initialValue}: ProviderProp setCreatedProjectSlug: (createdProjectSlug?: string) => { setOnboarding(prev => ({...prev, createdProjectSlug})); }, + messagingSetup: onboarding?.messagingSetup ?? UNCONFIGURED_SCM_MESSAGING_SETUP, + setMessagingSetup: (messagingSetup: ScmMessagingSetup) => { + setOnboarding(prev => ({...prev, messagingSetup})); + }, // Clear state derived from the selected repository (platform, features, // created project) without wiping the entire session. Use this when the // repo changes so downstream steps start fresh. @@ -116,10 +129,9 @@ export function OnboardingContextProvider({children, initialValue}: ProviderProp createdProjectSlug: undefined, })); }, - // Full-flow exits should clear every staged choice explicitly. Do not - // reach for a selected-platform reset to do this: clearing one field must - // stay local to that field so organization-scoped state added later - // survives local repository and platform changes. + // Full-flow exits should clear every staged choice explicitly. Do not use + // a selected-platform reset for this: messaging setup is organization- + // scoped and must survive local repository/platform changes. resetOnboarding: removeOnboarding, }), [onboarding, setOnboarding, removeOnboarding] diff --git a/static/app/components/onboarding/scm/scmMessagingSetup.ts b/static/app/components/onboarding/scm/scmMessagingSetup.ts new file mode 100644 index 000000000000..f81838e791e3 --- /dev/null +++ b/static/app/components/onboarding/scm/scmMessagingSetup.ts @@ -0,0 +1,16 @@ +export type ScmMessagingProviderKey = 'discord' | 'msteams' | 'slack'; + +export type ScmMessagingSetup = + | {mode: 'unconfigured'} + | {mode: 'skipped'} + | { + channelId: string; + integrationId: string; + mode: 'selected'; + providerKey: ScmMessagingProviderKey; + channelName?: string; + }; + +export const UNCONFIGURED_SCM_MESSAGING_SETUP = { + mode: 'unconfigured', +} as const satisfies ScmMessagingSetup; diff --git a/static/app/views/onboarding/components/onboardingSkipButton.spec.tsx b/static/app/views/onboarding/components/onboardingSkipButton.spec.tsx index 885e72e18494..8c49b6e30881 100644 --- a/static/app/views/onboarding/components/onboardingSkipButton.spec.tsx +++ b/static/app/views/onboarding/components/onboardingSkipButton.spec.tsx @@ -29,6 +29,11 @@ const MAPPED_CASES: MappedCase[] = [ sidebarSource: 'targeted_onboarding_scm_platform_features_skip', referrer: 'onboarding-scm-platform-features-skip', }, + { + stepId: OnboardingStepId.SCM_MESSAGING, + sidebarSource: 'onboarding_sidebar', + referrer: 'onboarding-scm-messaging-skip', + }, { stepId: OnboardingStepId.SETUP_DOCS, sidebarSource: 'targeted_onboarding_first_event_footer_skip', diff --git a/static/app/views/onboarding/components/onboardingSkipButton.tsx b/static/app/views/onboarding/components/onboardingSkipButton.tsx index 85680f380dec..39bd115012c4 100644 --- a/static/app/views/onboarding/components/onboardingSkipButton.tsx +++ b/static/app/views/onboarding/components/onboardingSkipButton.tsx @@ -28,6 +28,11 @@ const SKIP_CONFIG_BY_STEP: Partial sidebarSource: 'targeted_onboarding_scm_platform_features_skip', referrer: 'onboarding-scm-platform-features-skip', }, + [OnboardingStepId.SCM_MESSAGING]: { + // VDY-146 will add treatment-specific interaction analytics. + sidebarSource: 'onboarding_sidebar', + referrer: 'onboarding-scm-messaging-skip', + }, [OnboardingStepId.SETUP_DOCS]: { sidebarSource: 'targeted_onboarding_first_event_footer_skip', referrer: 'onboarding-first-event-footer-skip', diff --git a/static/app/views/onboarding/onboarding.spec.tsx b/static/app/views/onboarding/onboarding.spec.tsx index 2ab19c59b3b2..6fb4c836653c 100644 --- a/static/app/views/onboarding/onboarding.spec.tsx +++ b/static/app/views/onboarding/onboarding.spec.tsx @@ -553,6 +553,10 @@ describe('Onboarding', () => { it('navigates from welcome to scm-connect', async () => { const {router} = renderOnboarding('welcome'); + expect( + screen.getByRole('progressbar', {name: 'Onboarding progress'}) + ).toHaveAttribute('aria-valuemax', '4'); + await userEvent.click(screen.getByTestId('onboarding-welcome-start')); // Wait for scm-connect to render and its queries to resolve so the @@ -776,6 +780,96 @@ describe('Onboarding', () => { }); }); + it('adds the messaging route for treatment without creating a project', async () => { + ProjectsStore.loadInitialData([]); + const treatmentOrganization = OrganizationFixture({ + features: ['onboarding-scm-experiment', 'onboarding-scm-messaging-experiment'], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${treatmentOrganization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${treatmentOrganization.slug}/teams/`, + body: [], + }); + const createRequest = MockApiClient.addMockResponse({ + url: `/organizations/${treatmentOrganization.slug}/projects/`, + method: 'POST', + body: ProjectFixture(), + }); + + const {router} = render( + + + , + { + organization: treatmentOrganization, + initialRouterConfig: { + location: { + pathname: `/onboarding/${treatmentOrganization.slug}/scm-platform-features/`, + }, + route: '/onboarding/:orgId/:step/', + }, + } + ); + + expect( + screen.getByRole('progressbar', {name: 'Onboarding progress'}) + ).toHaveAttribute('aria-valuemax', '5'); + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + expect( + await screen.findByText('Get alerts where your team works') + ).toBeInTheDocument(); + expect(router.location.pathname).toBe( + `/onboarding/${treatmentOrganization.slug}/scm-messaging/` + ); + expect(createRequest).not.toHaveBeenCalled(); + }); + + it('global Skip exits treatment without creating a project and clears state', async () => { + const treatmentOrganization = OrganizationFixture({ + features: ['onboarding-scm-experiment', 'onboarding-scm-messaging-experiment'], + }); + const initialContext = { + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + messagingSetup: {mode: 'skipped' as const}, + }; + sessionStorage.setItem('onboarding', JSON.stringify(initialContext)); + const createRequest = MockApiClient.addMockResponse({ + url: `/organizations/${treatmentOrganization.slug}/projects/`, + method: 'POST', + body: ProjectFixture(), + }); + + render( + + + , + { + organization: treatmentOrganization, + initialRouterConfig: { + location: { + pathname: `/onboarding/${treatmentOrganization.slug}/scm-messaging/`, + }, + route: '/onboarding/:orgId/:step/', + }, + } + ); + + await userEvent.click(screen.getByRole('button', {name: 'Skip setup'})); + + expect(createRequest).not.toHaveBeenCalled(); + expect(sessionStorage.getItem('onboarding')).toBeNull(); + }); + it('preserves SCM context when going back from setup-docs', async () => { const nextJsProject = ProjectFixture({ platform: 'javascript-nextjs', @@ -811,6 +905,12 @@ describe('Onboarding', () => { const initialContext = { selectedPlatform: nextJsPlatform, selectedFeatures: [ProductSolution.ERROR_MONITORING], + messagingSetup: { + mode: 'selected' as const, + providerKey: 'slack' as const, + integrationId: '15', + channelId: 'C123', + }, }; // Seed sessionStorage directly so we can verify it's preserved after back @@ -843,6 +943,7 @@ describe('Onboarding', () => { expect(stored.selectedFeatures).toBeDefined(); // createdProjectSlug should be cleared so the user can re-create expect(stored.createdProjectSlug).toBeUndefined(); + expect(stored.messagingSetup).toEqual(initialContext.messagingSetup); }); describe('setup-docs analytics', () => { @@ -933,6 +1034,12 @@ describe('Onboarding', () => { selectedPlatform: nextJsPlatform, selectedFeatures: [ProductSolution.ERROR_MONITORING], createdProjectSlug: 'javascript-nextjs', + messagingSetup: { + mode: 'selected' as const, + providerKey: 'slack' as const, + integrationId: '15', + channelId: 'C123', + }, }; sessionStorage.setItem('onboarding', JSON.stringify(initialContext)); @@ -958,6 +1065,8 @@ describe('Onboarding', () => { // Integration and repo should be preserved expect(stored.selectedIntegration).toBeDefined(); expect(stored.selectedRepository).toBeDefined(); + // Messaging destinations are organization-scoped, not repo-derived. + expect(stored.messagingSetup).toEqual(initialContext.messagingSetup); }); it('navigates back from scm-connect to welcome', async () => { diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index e795cf1662fd..fe501c2c0999 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -40,6 +40,7 @@ import {NewWelcomeUI} from './components/newWelcome'; import {OnboardingSkipButton} from './components/onboardingSkipButton'; import {PlatformSelection} from './platformSelection'; import {ScmConnect} from './scmConnect'; +import {ScmMessaging} from './scmMessaging'; import {ScmPlatformFeatures} from './scmPlatformFeatures'; import {SetupDocs} from './setupDocs'; import {OnboardingStepId, type StepDescriptor, type StepProps} from './types'; @@ -99,7 +100,11 @@ function ScmConnectAdapter({onComplete, genBackButton}: StepProps) { ); } -function ScmPlatformFeaturesAdapter({onComplete, genBackButton}: StepProps) { +function ScmPlatformFeaturesAdapter({ + deferProjectCreation, + genBackButton, + onComplete, +}: StepProps & {deferProjectCreation: boolean}) { const { selectedRepository, selectedPlatform, @@ -116,6 +121,7 @@ function ScmPlatformFeaturesAdapter({onComplete, genBackButton}: StepProps) { selectedPlatform={selectedPlatform} selectedFeatures={selectedFeatures} createdProjectSlug={createdProjectSlug} + deferProjectCreation={deferProjectCreation} onPlatformChange={setSelectedPlatform} onFeaturesChange={setSelectedFeatures} onProjectCreated={setCreatedProjectSlug} @@ -125,7 +131,32 @@ function ScmPlatformFeaturesAdapter({onComplete, genBackButton}: StepProps) { ); } -const scmOnboardingSteps: StepDescriptor[] = [ +function ScmPlatformFeaturesControlAdapter(props: StepProps) { + return ; +} + +function ScmPlatformFeaturesTreatmentAdapter(props: StepProps) { + return ; +} + +function ScmMessagingAdapter({genBackButton}: StepProps) { + const {messagingSetup, selectedPlatform, setMessagingSetup} = useOnboardingContext(); + + if (!selectedPlatform) { + return null; + } + + return ( + + ); +} + +const scmOnboardingSharedSteps: StepDescriptor[] = [ { id: OnboardingStepId.WELCOME, title: t('Welcome'), @@ -138,10 +169,37 @@ const scmOnboardingSteps: StepDescriptor[] = [ Component: ScmConnectAdapter, cornerVariant: 'top-left', }, +]; + +const scmOnboardingSteps: StepDescriptor[] = [ + ...scmOnboardingSharedSteps, { id: OnboardingStepId.SCM_PLATFORM_FEATURES, title: t('Create your first project'), - Component: ScmPlatformFeaturesAdapter, + Component: ScmPlatformFeaturesControlAdapter, + cornerVariant: 'top-left', + }, + { + id: OnboardingStepId.SETUP_DOCS, + title: t('Install the Sentry SDK'), + Component: SetupDocs, + hasFooter: true, + cornerVariant: 'top-left', + }, +]; + +const scmMessagingOnboardingSteps: StepDescriptor[] = [ + ...scmOnboardingSharedSteps, + { + id: OnboardingStepId.SCM_PLATFORM_FEATURES, + title: t('Create your first project'), + Component: ScmPlatformFeaturesTreatmentAdapter, + cornerVariant: 'top-left', + }, + { + id: OnboardingStepId.SCM_MESSAGING, + title: t('Get alerts where your team works'), + Component: ScmMessagingAdapter, cornerVariant: 'top-left', }, { @@ -230,7 +288,18 @@ export function OnboardingWithoutContext() { reportExposure: isNewOrgOnboarding, }); - const onboardingSteps = hasScmOnboarding ? scmOnboardingSteps : legacyOnboardingSteps; + // VDY-146 owns treatment exposure and interaction analytics. For now the + // host consumes the nested assignment without reporting it. + const {inExperiment: hasScmMessaging} = useExperiment({ + feature: 'onboarding-scm-messaging-experiment', + reportExposure: false, + }); + + const onboardingSteps = hasScmOnboarding + ? hasScmMessaging + ? scmMessagingOnboardingSteps + : scmOnboardingSteps + : legacyOnboardingSteps; useReplayForCriticalFlow({ flowName: 'scm_onboarding', @@ -386,12 +455,15 @@ export function OnboardingWithoutContext() { }; // Redirect to the first step if we end up in an invalid state - const isInvalidDocsStep = stepId === 'setup-docs' && !projectSlug; - if (!stepObj || stepIndex === -1 || isInvalidDocsStep) { + const isInvalidDocsStep = stepId === OnboardingStepId.SETUP_DOCS && !projectSlug; + const isInvalidMessagingStep = + stepId === OnboardingStepId.SCM_MESSAGING && !onboardingContext.selectedPlatform; + if (!stepObj || stepIndex === -1 || isInvalidDocsStep || isInvalidMessagingStep) { + const fallbackStep = isInvalidMessagingStep + ? OnboardingStepId.SCM_PLATFORM_FEATURES + : onboardingSteps[0]!.id; return ( - + ); } @@ -414,6 +486,11 @@ export function OnboardingWithoutContext() { }} > { diff --git a/static/app/views/onboarding/scmMessaging.spec.tsx b/static/app/views/onboarding/scmMessaging.spec.tsx new file mode 100644 index 000000000000..6b23662142b5 --- /dev/null +++ b/static/app/views/onboarding/scmMessaging.spec.tsx @@ -0,0 +1,110 @@ +import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; + +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +import type {ScmMessagingSetup} from 'sentry/components/onboarding/scm/scmMessagingSetup'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; + +import {ScmMessaging} from './scmMessaging'; + +const selectedPlatform: OnboardingSelectedSDK = { + key: 'javascript-nextjs', + name: 'Next.js', + language: 'javascript', + type: 'framework', + link: null, + category: 'browser', +}; + +const selectedMessagingSetup: ScmMessagingSetup = { + mode: 'selected', + providerKey: 'slack', + integrationId: '15', + channelId: 'C123', +}; + +function renderMessaging( + onMessagingSetupChange = jest.fn(), + messagingSetup = selectedMessagingSetup +) { + return render( + + ); +} + +describe('ScmMessaging', () => { + afterEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('revalidates a restored destination before showing it as selected', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + body: [OrganizationIntegrationsFixture({id: '15'})], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/15/channels/', + body: { + results: [{id: 'C123', name: 'alerts', display: '#alerts', type: 'text'}], + }, + }); + const onMessagingSetupChange = jest.fn(); + + renderMessaging(onMessagingSetupChange); + + expect(await screen.findByText('Destination selected')).toBeInTheDocument(); + await waitFor(() => { + expect(onMessagingSetupChange).toHaveBeenCalledWith({ + ...selectedMessagingSetup, + channelName: '#alerts', + }); + }); + }); + + it('clears a missing integration with an explanation', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + body: [], + }); + const onMessagingSetupChange = jest.fn(); + + renderMessaging(onMessagingSetupChange); + + expect( + await screen.findByText( + "We couldn't find the saved integration. Choose a destination again." + ) + ).toBeInTheDocument(); + expect(onMessagingSetupChange).toHaveBeenCalledWith({mode: 'unconfigured'}); + expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); + }); + + it('clears a missing channel with an explanation', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + body: [OrganizationIntegrationsFixture({id: '15'})], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/15/channels/', + body: {results: []}, + }); + const onMessagingSetupChange = jest.fn(); + + renderMessaging(onMessagingSetupChange); + + expect( + await screen.findByText( + "We couldn't find the saved channel. Choose a destination again." + ) + ).toBeInTheDocument(); + expect(onMessagingSetupChange).toHaveBeenCalledWith({mode: 'unconfigured'}); + expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/onboarding/scmMessaging.tsx b/static/app/views/onboarding/scmMessaging.tsx new file mode 100644 index 000000000000..d37d0b016906 --- /dev/null +++ b/static/app/views/onboarding/scmMessaging.tsx @@ -0,0 +1,208 @@ +import {useEffect, useMemo, useState} from 'react'; +import {skipToken, useQuery} from '@tanstack/react-query'; + +import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import type {ScmMessagingSetup} from 'sentry/components/onboarding/scm/scmMessagingSetup'; +import {t} from 'sentry/locale'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {SCM_STEP_CONTENT_WIDTH} from 'sentry/views/onboarding/consts'; + +import type {StepProps} from './types'; + +type Channel = { + display: string; + id: string; + name: string; +}; + +type ChannelListResponse = { + results: Channel[]; +}; + +type StaleDestinationReason = 'channel' | 'integration'; + +interface ScmMessagingProps { + messagingSetup: ScmMessagingSetup; + onMessagingSetupChange: (messagingSetup: ScmMessagingSetup) => void; + selectedPlatform: OnboardingSelectedSDK; + genBackButton?: StepProps['genBackButton']; +} + +/** + * Revalidates the organization-scoped identifiers stored in session state. + * A restored selection is not usable until both queries succeed and resolve + * the saved integration and channel. + */ +export function useScmMessagingSetupValidation({ + messagingSetup, + onMessagingSetupChange, +}: Pick) { + const organization = useOrganization(); + const [staleReason, setStaleReason] = useState(); + const hasSelectedDestination = messagingSetup.mode === 'selected'; + + const integrationsQuery = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/', + { + path: hasSelectedDestination + ? {organizationIdOrSlug: organization.slug} + : skipToken, + query: {integrationType: 'messaging'}, + staleTime: 0, + } + ) + ); + + const integration = useMemo(() => { + if (!hasSelectedDestination) { + return; + } + + return integrationsQuery.data?.find( + item => + item.id === messagingSetup.integrationId && + (item.provider.key === messagingSetup.providerKey || + item.provider.slug === messagingSetup.providerKey) && + item.status === 'active' && + item.organizationIntegrationStatus === 'active' + ); + }, [hasSelectedDestination, integrationsQuery.data, messagingSetup]); + + const channelsQuery = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/$integrationId/channels/', + { + path: integration + ? { + organizationIdOrSlug: organization.slug, + integrationId: integration.id, + } + : skipToken, + staleTime: 0, + } + ) + ); + + const channel = useMemo(() => { + if (!hasSelectedDestination) { + return; + } + return channelsQuery.data?.results.find(item => item.id === messagingSetup.channelId); + }, [channelsQuery.data, hasSelectedDestination, messagingSetup]); + + useEffect(() => { + if (messagingSetup.mode === 'selected') { + setStaleReason(undefined); + } + }, [messagingSetup]); + + useEffect(() => { + if (messagingSetup.mode !== 'selected' || !integrationsQuery.isSuccess) { + return; + } + + if (!integration) { + setStaleReason('integration'); + onMessagingSetupChange({mode: 'unconfigured'}); + return; + } + + if (!channelsQuery.isSuccess) { + return; + } + + if (!channel) { + setStaleReason('channel'); + onMessagingSetupChange({mode: 'unconfigured'}); + return; + } + + const channelName = channel.display || channel.name; + if (channelName !== messagingSetup.channelName) { + onMessagingSetupChange({...messagingSetup, channelName}); + } + }, [ + channel, + channelsQuery.isSuccess, + integration, + integrationsQuery.isSuccess, + messagingSetup, + onMessagingSetupChange, + ]); + + return { + isError: + hasSelectedDestination && + (integrationsQuery.isError || (integration !== undefined && channelsQuery.isError)), + isPending: + hasSelectedDestination && + (integrationsQuery.isPending || + (integration !== undefined && channelsQuery.isPending)), + isValid: hasSelectedDestination && channel !== undefined, + staleReason, + }; +} + +export function ScmMessaging({ + genBackButton, + messagingSetup, + onMessagingSetupChange, + selectedPlatform, +}: ScmMessagingProps) { + const validation = useScmMessagingSetupValidation({ + messagingSetup, + onMessagingSetupChange, + }); + + return ( + + + + + {t('Get alerts where your team works')} + + + {t( + "Choose where to send alerts for your %s project. We'll create the project and its alert rules when you continue.", + selectedPlatform.name + )} + + + + {validation.staleReason === 'integration' && ( + + {t("We couldn't find the saved integration. Choose a destination again.")} + + )} + {validation.staleReason === 'channel' && ( + + {t("We couldn't find the saved channel. Choose a destination again.")} + + )} + {validation.isError && ( + + {t("We couldn't check the saved destination. Reload the page to try again.")} + + )} + {validation.isPending && ( + {t('Checking saved destination')} + )} + {validation.isValid && ( + + {t('Destination selected')} + + )} + + {t('Email alerts will be included by default')} + {genBackButton?.()} + + + ); +} diff --git a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx index 0f85ab25530e..753833ba6498 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx @@ -69,6 +69,7 @@ function defaultProps(state: StateOverrides = {}) { selectedPlatform: state.selectedPlatform, selectedFeatures: state.selectedFeatures, createdProjectSlug: state.createdProjectSlug, + deferProjectCreation: false, onPlatformChange: jest.fn(), onFeaturesChange: jest.fn(), onProjectCreated: jest.fn(), @@ -626,6 +627,31 @@ describe('ScmPlatformFeatures', () => { }); }); + it('defers project creation for the messaging treatment', async () => { + const createRequest = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${adminTeam.slug}/projects/`, + method: 'POST', + body: ProjectFixture(), + }); + const props = { + ...defaultProps({ + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }), + deferProjectCreation: true, + }; + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Continue'})); + + expect(createRequest).not.toHaveBeenCalled(); + expect(props.onProjectCreated).not.toHaveBeenCalled(); + expect(props.onComplete).toHaveBeenCalledWith(nextJsPlatform, { + product: [ProductSolution.ERROR_MONITORING], + }); + }); + it('auto-creates the project on Continue and forwards selected features', async () => { const createdProject = ProjectFixture({ slug: 'javascript-nextjs', diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index 18ae339c6c66..1ad8a6f97c79 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -32,6 +32,7 @@ import type {StepProps} from './types'; interface ScmPlatformFeaturesProps { createdProjectSlug: string | undefined; + deferProjectCreation: boolean; onComplete: StepProps['onComplete']; onFeaturesChange: (features: ProductSolution[] | undefined) => void; onPlatformChange: (platform: OnboardingSelectedSDK | undefined) => void; @@ -44,6 +45,7 @@ interface ScmPlatformFeaturesProps { export function ScmPlatformFeatures({ createdProjectSlug, + deferProjectCreation, onComplete, onFeaturesChange, onPlatformChange, @@ -91,9 +93,10 @@ export function ScmPlatformFeatures({ ? projects.find(p => p.slug === createdProjectSlug) : undefined; - // Continue auto-creates the project, which needs the teams and projects - // stores loaded. - const autoCreateDataPending = isLoadingTeams || !projectsLoaded; + // Control auto-creates the project and needs both stores loaded. Treatment + // only stages platform/features here; its messaging step owns creation. + const autoCreateDataPending = + !deferProjectCreation && (isLoadingTeams || !projectsLoaded); async function handleContinue() { if (isCompletingRef.current) { @@ -117,6 +120,11 @@ export function ScmPlatformFeatures({ } const platform = selectedPlatform ?? toSelectedSdk(info); + if (deferProjectCreation) { + onComplete(platform, {product: currentFeatures}); + return; + } + // If a project was already created for this platform (e.g. the user // went back after the project received its first event), reuse it. // If the platform changed, abandon the old project and create a new diff --git a/static/app/views/onboarding/types.ts b/static/app/views/onboarding/types.ts index 80ef001f36d5..32ea32166db2 100644 --- a/static/app/views/onboarding/types.ts +++ b/static/app/views/onboarding/types.ts @@ -26,6 +26,7 @@ export enum OnboardingStepId { SETUP_DOCS = 'setup-docs', // SCM-first onboarding flow SCM_CONNECT = 'scm-connect', + SCM_MESSAGING = 'scm-messaging', SCM_PLATFORM_FEATURES = 'scm-platform-features', } From fc26dd9aeadb54378ec8bf204f0ba1c9207fb0c5 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 31 Jul 2026 13:30:50 -0500 Subject: [PATCH 2/3] fix(onboarding): Preserve validated messaging state Make selected-platform clearing non-destructive while keeping full legacy exits explicit. Require fresh integration and channel results before treating a restored destination as valid. Refs VDY-140 --- .../onboarding/onboardingContext.spec.tsx | 31 ++++++++++ .../app/views/onboarding/onboarding.spec.tsx | 24 ++++++++ .../views/onboarding/scmMessaging.spec.tsx | 61 +++++++++++++++++++ static/app/views/onboarding/scmMessaging.tsx | 12 +++- 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/static/app/components/onboarding/onboardingContext.spec.tsx b/static/app/components/onboarding/onboardingContext.spec.tsx index eea81c227e64..96ff0acf88fb 100644 --- a/static/app/components/onboarding/onboardingContext.spec.tsx +++ b/static/app/components/onboarding/onboardingContext.spec.tsx @@ -121,6 +121,37 @@ describe('OnboardingContextProvider', () => { expect(screen.getByText('messaging:selected')).toBeInTheDocument(); }); + + it('preserves messaging setup when clearing the selected platform', async () => { + render( + + + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Clear platform'})); + + expect(screen.getByText('no-platform')).toBeInTheDocument(); + expect(screen.getByText('messaging:selected')).toBeInTheDocument(); + expect(JSON.parse(sessionStorage.getItem('onboarding') ?? '{}')).toMatchObject({ + messagingSetup: { + mode: 'selected', + providerKey: 'slack', + integrationId: '15', + channelId: 'C123', + }, + }); + }); }); describe('OnboardingContextProvider session semantics', () => { diff --git a/static/app/views/onboarding/onboarding.spec.tsx b/static/app/views/onboarding/onboarding.spec.tsx index 6fb4c836653c..598c595ac2f9 100644 --- a/static/app/views/onboarding/onboarding.spec.tsx +++ b/static/app/views/onboarding/onboarding.spec.tsx @@ -582,6 +582,30 @@ describe('Onboarding', () => { ); }); + it('preserves messaging setup when returning to the welcome step', async () => { + const messagingSetup = { + mode: 'selected' as const, + providerKey: 'slack' as const, + integrationId: '15', + channelId: 'C123', + }; + + renderOnboarding('welcome', { + initialContext: { + selectedPlatform: nextJsPlatform, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + messagingSetup, + }, + }); + + await waitFor(() => { + const stored = JSON.parse(sessionStorage.getItem('onboarding') ?? '{}'); + expect(stored.selectedPlatform).toBeUndefined(); + expect(stored.selectedFeatures).toBeUndefined(); + expect(stored.messagingSetup).toEqual(messagingSetup); + }); + }); + it('fires scm_welcome_continue_clicked on start click and not the legacy event', async () => { renderOnboarding('welcome'); diff --git a/static/app/views/onboarding/scmMessaging.spec.tsx b/static/app/views/onboarding/scmMessaging.spec.tsx index 6b23662142b5..eefb9b7c6e25 100644 --- a/static/app/views/onboarding/scmMessaging.spec.tsx +++ b/static/app/views/onboarding/scmMessaging.spec.tsx @@ -1,9 +1,13 @@ +import {QueryClientProvider} from '@tanstack/react-query'; import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; +import {makeTestQueryClient} from 'sentry-test/queryClient'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import type {ScmMessagingSetup} from 'sentry/components/onboarding/scm/scmMessagingSetup'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {ScmMessaging} from './scmMessaging'; @@ -107,4 +111,61 @@ describe('ScmMessaging', () => { expect(onMessagingSetupChange).toHaveBeenCalledWith({mode: 'unconfigured'}); expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); }); + + it('does not trust a cached destination while revalidating it', async () => { + const queryClient = makeTestQueryClient(); + const integration = OrganizationIntegrationsFixture({id: '15'}); + const channel = {id: 'C123', name: 'alerts', display: '#alerts', type: 'text'}; + const integrationsOptions = apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/', + { + path: {organizationIdOrSlug: 'org-slug'}, + query: {integrationType: 'messaging'}, + staleTime: 0, + } + ); + const channelsOptions = apiOptions.as<{results: Array}>()( + '/organizations/$organizationIdOrSlug/integrations/$integrationId/channels/', + { + path: {organizationIdOrSlug: 'org-slug', integrationId: '15'}, + staleTime: 0, + } + ); + queryClient.setQueryData(integrationsOptions.queryKey, { + json: [integration], + headers: {}, + }); + queryClient.setQueryData(channelsOptions.queryKey, { + json: {results: [channel]}, + headers: {}, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/15/channels/', + body: {results: [channel]}, + }); + const onMessagingSetupChange = jest.fn(); + + render( + + + + ); + + expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); + expect( + await screen.findByText( + "We couldn't find the saved integration. Choose a destination again." + ) + ).toBeInTheDocument(); + expect(onMessagingSetupChange).toHaveBeenCalledWith({mode: 'unconfigured'}); + }); }); diff --git a/static/app/views/onboarding/scmMessaging.tsx b/static/app/views/onboarding/scmMessaging.tsx index d37d0b016906..5f66a944e5fe 100644 --- a/static/app/views/onboarding/scmMessaging.tsx +++ b/static/app/views/onboarding/scmMessaging.tsx @@ -143,9 +143,15 @@ export function useScmMessagingSetupValidation({ (integrationsQuery.isError || (integration !== undefined && channelsQuery.isError)), isPending: hasSelectedDestination && - (integrationsQuery.isPending || - (integration !== undefined && channelsQuery.isPending)), - isValid: hasSelectedDestination && channel !== undefined, + (integrationsQuery.isFetching || + (integration !== undefined && channelsQuery.isFetching)), + isValid: + hasSelectedDestination && + integrationsQuery.isSuccess && + !integrationsQuery.isFetching && + channelsQuery.isSuccess && + !channelsQuery.isFetching && + channel !== undefined, staleReason, }; } From 9601f27203d7449dda57c1a8b0949a3055a9bcd2 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 31 Jul 2026 16:52:47 -0500 Subject: [PATCH 3/3] fix(onboarding): Keep saved destination on empty channel list Un-export useScmMessagingSetupValidation and ScmMessagingProviderKey; both lacked a second consumer and failed knip. VDY-143 lifts the hook out when the inline picker needs it. Every provider helper in organization_integration_channels.py returns an empty list when the upstream API call fails, so results: [] cannot be distinguished from a deleted channel. Resetting on it discarded a valid destination during a transient Slack or Discord outage. Only a populated list that omits the saved channel now counts as stale. --- .../onboarding/onboardingContext.spec.tsx | 45 ++++++------------- .../onboarding/scm/scmMessagingSetup.ts | 2 +- .../views/onboarding/scmMessaging.spec.tsx | 34 +++++++++++++- static/app/views/onboarding/scmMessaging.tsx | 20 ++++++++- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/static/app/components/onboarding/onboardingContext.spec.tsx b/static/app/components/onboarding/onboardingContext.spec.tsx index 96ff0acf88fb..c21dd551c18f 100644 --- a/static/app/components/onboarding/onboardingContext.spec.tsx +++ b/static/app/components/onboarding/onboardingContext.spec.tsx @@ -121,11 +121,18 @@ describe('OnboardingContextProvider', () => { expect(screen.getByText('messaging:selected')).toBeInTheDocument(); }); +}); + +describe('OnboardingContextProvider session semantics', () => { + afterEach(() => { + window.sessionStorage.clear(); + }); - it('preserves messaging setup when clearing the selected platform', async () => { + it('keeps the rest of the session when clearing the selected platform', async () => { render( { await userEvent.click(screen.getByRole('button', {name: 'Clear platform'})); + // Clearing one field must stay local to that field. This previously routed + // through removeOnboarding and wiped the whole session, taking the connected + // repository with it. Messaging destinations are organization-scoped, so they + // must survive a platform change too. expect(screen.getByText('no-platform')).toBeInTheDocument(); + expect(screen.getByText('repo:42')).toBeInTheDocument(); expect(screen.getByText('messaging:selected')).toBeInTheDocument(); expect(JSON.parse(sessionStorage.getItem('onboarding') ?? '{}')).toMatchObject({ + selectedRepository: {id: '42'}, messagingSetup: { mode: 'selected', providerKey: 'slack', @@ -152,36 +165,6 @@ describe('OnboardingContextProvider', () => { }, }); }); -}); - -describe('OnboardingContextProvider session semantics', () => { - afterEach(() => { - window.sessionStorage.clear(); - }); - - it('keeps the rest of the session when clearing the selected platform', async () => { - render( - - - - ); - - await userEvent.click(screen.getByRole('button', {name: 'Clear platform'})); - - // Clearing one field must stay local to that field. This previously routed - // through removeOnboarding and wiped the whole session, taking the - // connected repository with it. - expect(screen.getByText('no-platform')).toBeInTheDocument(); - expect(screen.getByText('repo:42')).toBeInTheDocument(); - expect(JSON.parse(sessionStorage.getItem('onboarding') ?? '{}')).toMatchObject({ - selectedRepository: {id: '42'}, - }); - }); it('clears persisted session state on resetOnboarding', async () => { // Seeded through sessionStorage rather than the initialValue prop: diff --git a/static/app/components/onboarding/scm/scmMessagingSetup.ts b/static/app/components/onboarding/scm/scmMessagingSetup.ts index f81838e791e3..af9e72214b57 100644 --- a/static/app/components/onboarding/scm/scmMessagingSetup.ts +++ b/static/app/components/onboarding/scm/scmMessagingSetup.ts @@ -1,4 +1,4 @@ -export type ScmMessagingProviderKey = 'discord' | 'msteams' | 'slack'; +type ScmMessagingProviderKey = 'discord' | 'msteams' | 'slack'; export type ScmMessagingSetup = | {mode: 'unconfigured'} diff --git a/static/app/views/onboarding/scmMessaging.spec.tsx b/static/app/views/onboarding/scmMessaging.spec.tsx index eefb9b7c6e25..c417ef0a8d06 100644 --- a/static/app/views/onboarding/scmMessaging.spec.tsx +++ b/static/app/views/onboarding/scmMessaging.spec.tsx @@ -95,9 +95,11 @@ describe('ScmMessaging', () => { match: [MockApiClient.matchQuery({integrationType: 'messaging'})], body: [OrganizationIntegrationsFixture({id: '15'})], }); + // A populated list that does not contain the saved channel is genuine + // staleness: the channel was deleted or renamed away. MockApiClient.addMockResponse({ url: '/organizations/org-slug/integrations/15/channels/', - body: {results: []}, + body: {results: [{id: 'C999', name: 'general', display: '#general'}]}, }); const onMessagingSetupChange = jest.fn(); @@ -112,6 +114,36 @@ describe('ScmMessaging', () => { expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); }); + it('keeps the saved destination when the channel list comes back empty', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + body: [OrganizationIntegrationsFixture({id: '15'})], + }); + // Every provider helper returns [] when the upstream API fails, so an empty + // list is indistinguishable from an outage and must not discard the + // selection. It stays non-submittable rather than being reset. + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/15/channels/', + body: {results: []}, + }); + const onMessagingSetupChange = jest.fn(); + + renderMessaging(onMessagingSetupChange); + + await waitFor(() => { + expect(screen.queryByText('Checking saved destination')).not.toBeInTheDocument(); + }); + + expect(onMessagingSetupChange).not.toHaveBeenCalled(); + expect(screen.queryByText('Destination selected')).not.toBeInTheDocument(); + expect( + screen.queryByText( + "We couldn't find the saved channel. Choose a destination again." + ) + ).not.toBeInTheDocument(); + }); + it('does not trust a cached destination while revalidating it', async () => { const queryClient = makeTestQueryClient(); const integration = OrganizationIntegrationsFixture({id: '15'}); diff --git a/static/app/views/onboarding/scmMessaging.tsx b/static/app/views/onboarding/scmMessaging.tsx index 5f66a944e5fe..7113702e106a 100644 --- a/static/app/views/onboarding/scmMessaging.tsx +++ b/static/app/views/onboarding/scmMessaging.tsx @@ -38,8 +38,11 @@ interface ScmMessagingProps { * Revalidates the organization-scoped identifiers stored in session state. * A restored selection is not usable until both queries succeed and resolve * the saved integration and channel. + * + * File-local by design: VDY-143 will lift this out once the inline destination + * picker needs it. Exporting it before it has a second consumer trips knip. */ -export function useScmMessagingSetupValidation({ +function useScmMessagingSetupValidation({ messagingSetup, onMessagingSetupChange, }: Pick) { @@ -118,6 +121,15 @@ export function useScmMessagingSetupValidation({ return; } + // Every provider helper in organization_integration_channels.py returns an + // empty list when the upstream API call fails, so `results: []` cannot be + // told apart from "the saved channel was deleted". Treating it as stale + // would discard a valid destination on a transient Slack/Discord outage, + // so leave the selection alone and let isValid keep it non-submittable. + if (channelsQuery.data.results.length === 0) { + return; + } + if (!channel) { setStaleReason('channel'); onMessagingSetupChange({mode: 'unconfigured'}); @@ -128,8 +140,14 @@ export function useScmMessagingSetupValidation({ if (channelName !== messagingSetup.channelName) { onMessagingSetupChange({...messagingSetup, channelName}); } + // `messagingSetup` stays in the deps because the spread above needs the whole + // object. This effect writes a new object through onMessagingSetupChange, so + // it re-runs on its own write and only settles because the channelName + // comparison becomes false. Any future field written unconditionally here + // turns that fixed point into a session-storage write loop. }, [ channel, + channelsQuery.data, channelsQuery.isSuccess, integration, integrationsQuery.isSuccess,