Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions static/app/components/onboarding/onboardingContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const platform = {

function StateConsumer() {
const {
messagingSetup,
selectedRepository,
selectedPlatform,
selectedFeatures,
Expand All @@ -32,6 +33,7 @@ function StateConsumer() {
<div>
{selectedFeatures ? `features:${selectedFeatures.length}` : 'no-features'}
</div>
<div>{`messaging:${messagingSetup.mode}`}</div>
<button onClick={() => setSelectedPlatform(undefined)}>Clear platform</button>
<button onClick={() => resetOnboarding()}>Reset onboarding</button>
</div>
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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',
},
}}
>
<StateConsumer />
Expand All @@ -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(
<OnboardingContextProvider>
<StateConsumer />
</OnboardingContextProvider>
);
expect(screen.getByText('messaging:selected')).toBeInTheDocument();

firstRender.unmount();
render(
<OnboardingContextProvider>
<StateConsumer />
</OnboardingContextProvider>
);

expect(screen.getByText('messaging:selected')).toBeInTheDocument();
});
});

Expand All @@ -94,6 +134,12 @@ describe('OnboardingContextProvider session semantics', () => {
initialValue={{
selectedRepository: RepositoryFixture({id: '42'}),
selectedPlatform: platform,
messagingSetup: {
mode: 'selected',
providerKey: 'slack',
integrationId: '15',
channelId: 'C123',
},
}}
>
<StateConsumer />
Expand All @@ -103,12 +149,20 @@ describe('OnboardingContextProvider session semantics', () => {
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.
// 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',
integrationId: '15',
channelId: 'C123',
},
});
});

Expand Down
20 changes: 16 additions & 4 deletions static/app/components/onboarding/onboardingContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +28,7 @@ type OnboardingContextProps = {

type OnboardingSessionState = {
createdProjectSlug?: string;
messagingSetup?: ScmMessagingSetup;
selectedFeatures?: ProductSolution[];
selectedIntegration?: Integration;
selectedPlatform?: OnboardingSelectedSDK;
Expand All @@ -42,6 +49,8 @@ const OnboardingContext = createContext<OnboardingContextProps>({
setSelectedFeatures: () => {},
createdProjectSlug: undefined,
setCreatedProjectSlug: () => {},
messagingSetup: UNCONFIGURED_SCM_MESSAGING_SETUP,
setMessagingSetup: () => {},
clearDerivedState: () => {},
resetOnboarding: () => {},
});
Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down
16 changes: 16 additions & 0 deletions static/app/components/onboarding/scm/scmMessagingSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const SKIP_CONFIG_BY_STEP: Partial<Record<OnboardingStepId, SkipAnalyticsConfig>
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',
Expand Down
133 changes: 133 additions & 0 deletions static/app/views/onboarding/onboarding.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -578,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');

Expand Down Expand Up @@ -776,6 +804,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(
<OnboardingContextProvider
initialValue={{
selectedPlatform: nextJsPlatform,
selectedFeatures: [ProductSolution.ERROR_MONITORING],
}}
>
<OnboardingWithoutContext />
</OnboardingContextProvider>,
{
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(
<OnboardingContextProvider initialValue={initialContext}>
<OnboardingWithoutContext />
</OnboardingContextProvider>,
{
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',
Expand Down Expand Up @@ -811,6 +929,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
Expand Down Expand Up @@ -843,6 +967,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', () => {
Expand Down Expand Up @@ -933,6 +1058,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));
Expand All @@ -958,6 +1089,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 () => {
Expand Down
Loading
Loading