diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 1db8257436d939..cd5019db2e7029 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -19,7 +19,7 @@ type OnboardingContextProps = { selectedRepository?: Repository; }; -export type OnboardingSessionState = { +type OnboardingSessionState = { createdProjectSlug?: string; selectedFeatures?: ProductSolution[]; selectedIntegration?: Integration; diff --git a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx index b31fb60d67205c..27f7634b07b7dc 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.spec.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.spec.tsx @@ -4,10 +4,7 @@ import {RepositoryFixture} from 'sentry-fixture/repository'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {Repository} from 'sentry/types/integrations'; import {ScmRepoSelector} from './scmRepoSelector'; @@ -26,16 +23,6 @@ jest.mock('@tanstack/react-virtual', () => ({ })), })); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); - }; -} - describe('ScmRepoSelector', () => { const organization = OrganizationFixture(); @@ -54,21 +41,34 @@ describe('ScmRepoSelector', () => { }, }); + let onRepositoryChange: jest.Mock; + + beforeEach(() => { + onRepositoryChange = jest.fn(); + }); + afterEach(() => { MockApiClient.clearMockResponses(); - sessionStorage.clear(); }); + function renderSelector(selectedRepository?: Repository) { + return render( + , + {organization} + ); + } + it('renders search placeholder', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, body: {repos: []}, }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + renderSelector(); expect(screen.getByText('Search repositories')).toBeInTheDocument(); }); @@ -79,10 +79,7 @@ describe('ScmRepoSelector', () => { body: {repos: []}, }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + renderSelector(); await userEvent.click(screen.getByRole('textbox')); @@ -100,10 +97,7 @@ describe('ScmRepoSelector', () => { body: {detail: 'Internal Error'}, }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + renderSelector(); await userEvent.click(screen.getByRole('textbox')); @@ -123,10 +117,7 @@ describe('ScmRepoSelector', () => { }, }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + renderSelector(); await userEvent.click(screen.getByRole('textbox')); @@ -136,7 +127,7 @@ describe('ScmRepoSelector', () => { expect(screen.getByRole('menuitemradio', {name: 'relay'})).toBeInTheDocument(); }); - it('shows selected repo value when one is in context', () => { + it('shows selected repo value when one is provided via props', () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, body: {repos: []}, @@ -147,10 +138,7 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + renderSelector(selectedRepo); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); }); @@ -173,10 +161,7 @@ describe('ScmRepoSelector', () => { ], }); - render(, { - organization, - wrapper: makeOnboardingWrapper(), - }); + renderSelector(); await userEvent.click(screen.getByRole('textbox')); await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'})); @@ -184,7 +169,7 @@ describe('ScmRepoSelector', () => { await waitFor(() => expect(reposLookup).toHaveBeenCalled()); }); - it('clears the selected repo', async () => { + it('calls onRepositoryChange with undefined when clearing', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`, body: {repos: []}, @@ -195,17 +180,14 @@ describe('ScmRepoSelector', () => { externalSlug: 'getsentry/old-repo', }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + renderSelector(selectedRepo); expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument(); await userEvent.click(await screen.findByTestId('icon-close')); await waitFor(() => { - expect(screen.queryByText('getsentry/old-repo')).not.toBeInTheDocument(); + expect(onRepositoryChange).toHaveBeenCalledWith(undefined); }); }); @@ -225,10 +207,7 @@ describe('ScmRepoSelector', () => { }, }); - render(, { - organization, - wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}), - }); + renderSelector(selectedRepo); await userEvent.click(screen.getByRole('textbox')); diff --git a/static/app/views/onboarding/components/scmRepoSelector.tsx b/static/app/views/onboarding/components/scmRepoSelector.tsx index 9d35f370dd5426..a32a5d3e488d12 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.tsx @@ -2,9 +2,8 @@ import {useMemo} from 'react'; import {Select} from '@sentry/scraps/select'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -15,12 +14,16 @@ import {useScmRepoSelection} from './useScmRepoSelection'; interface ScmRepoSelectorProps { integration: Integration; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedRepository: Repository | undefined; } -export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { +export function ScmRepoSelector({ + integration, + onRepositoryChange, + selectedRepository, +}: ScmRepoSelectorProps) { const organization = useOrganization(); - const {selectedRepository, setSelectedRepository, clearDerivedState} = - useOnboardingContext(); const {reposByIdentifier, dropdownItems, isFetching, isError} = useScmRepos( integration.id, selectedRepository @@ -28,7 +31,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { const {busy, handleSelect, handleRemove} = useScmRepoSelection({ integration, - onSelect: setSelectedRepository, + onSelect: onRepositoryChange, reposByIdentifier, }); @@ -50,10 +53,6 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { }, [dropdownItems, selectedRepository]); function handleChange(option: {value: string} | null) { - // Changing or clearing the repo invalidates downstream state (platform, - // features, created project) which are all derived from the selected repo. - clearDerivedState(); - if (option === null) { handleRemove(); } else { diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index a535f6b3e3db46..9a9e2825c5a9bc 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -19,6 +19,7 @@ import {categoryList} from 'sentry/data/platformPickerCategories'; import {allPlatforms as platforms} from 'sentry/data/platforms'; import {IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {PlatformKey} from 'sentry/types/project'; import {defined} from 'sentry/utils'; @@ -67,6 +68,73 @@ const legacyOnboardingSteps: StepDescriptor[] = [ }, ]; +// Adapter wrappers that read from OnboardingContext and pass props to the +// decoupled SCM step components. This keeps the step components reusable +// across onboarding and the future project creation flow. + +function ScmConnectAdapter({onComplete}: StepProps) { + const { + selectedIntegration, + setSelectedIntegration, + selectedRepository, + setSelectedRepository, + clearDerivedState, + } = useOnboardingContext(); + + const handleRepositoryChange = useCallback( + (repo: Repository | undefined) => { + clearDerivedState(); + setSelectedRepository(repo); + }, + [clearDerivedState, setSelectedRepository] + ); + + return ( + + ); +} + +function ScmPlatformFeaturesAdapter({onComplete}: StepProps) { + const { + selectedRepository, + selectedPlatform, + setSelectedPlatform, + selectedFeatures, + setSelectedFeatures, + } = useOnboardingContext(); + + return ( + + ); +} + +function ScmProjectDetailsAdapter({onComplete}: StepProps) { + const {selectedPlatform, selectedFeatures, setCreatedProjectSlug} = + useOnboardingContext(); + + return ( + + ); +} + const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.WELCOME, @@ -77,19 +145,19 @@ const scmOnboardingSteps: StepDescriptor[] = [ { id: OnboardingStepId.SCM_CONNECT, title: t('Connect repository'), - Component: ScmConnect, + Component: ScmConnectAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PLATFORM_FEATURES, title: t('Platform & features'), - Component: ScmPlatformFeatures, + Component: ScmPlatformFeaturesAdapter, cornerVariant: 'top-left', }, { id: OnboardingStepId.SCM_PROJECT_DETAILS, title: t('Project details'), - Component: ScmProjectDetails, + Component: ScmProjectDetailsAdapter, cornerVariant: 'top-left', }, { diff --git a/static/app/views/onboarding/scmConnect.tsx b/static/app/views/onboarding/scmConnect.tsx index 07d30748c50812..07b6a1afa929b0 100644 --- a/static/app/views/onboarding/scmConnect.tsx +++ b/static/app/views/onboarding/scmConnect.tsx @@ -7,10 +7,9 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {IconCheckmark} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {Integration} from 'sentry/types/integrations'; +import type {Integration, Repository} from 'sentry/types/integrations'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -22,16 +21,23 @@ import {ScmStepHeader} from './components/scmStepHeader'; import {useScmPlatformDetection} from './components/useScmPlatformDetection'; import {useScmProviders} from './components/useScmProviders'; import {SCM_STEP_CONTENT_WIDTH} from './consts'; -import type {StepProps} from './types'; -export function ScmConnect({onComplete}: StepProps) { +interface ScmConnectProps { + onComplete: () => void; + onIntegrationChange: (integration: Integration | undefined) => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedIntegration: Integration | undefined; + selectedRepository: Repository | undefined; +} + +export function ScmConnect({ + onComplete, + onIntegrationChange, + onRepositoryChange, + selectedIntegration, + selectedRepository, +}: ScmConnectProps) { const organization = useOrganization(); - const { - selectedIntegration, - setSelectedIntegration, - selectedRepository, - setSelectedRepository, - } = useOnboardingContext(); const { scmProviders, isPending, @@ -53,11 +59,11 @@ export function ScmConnect({onComplete}: StepProps) { const handleInstall = useCallback( (data: Integration) => { - setSelectedIntegration(data); - setSelectedRepository(undefined); + onIntegrationChange(data); + onRepositoryChange(undefined); refetchIntegrations(); }, - [setSelectedIntegration, setSelectedRepository, refetchIntegrations] + [onIntegrationChange, onRepositoryChange, refetchIntegrations] ); return ( @@ -93,7 +99,11 @@ export function ScmConnect({onComplete}: StepProps) { )} - + {selectedRepository ? ( { if (effectiveIntegration && !selectedIntegration) { - setSelectedIntegration(effectiveIntegration); + onIntegrationChange(effectiveIntegration); } onComplete(); }} diff --git a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx index 2d11e246cdc512..5e741dce95b9f1 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx @@ -6,12 +6,9 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import {openConsoleModal, openModal} from 'sentry/actionCreators/modal'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {Repository} from 'sentry/types/integrations'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {ScmPlatformFeatures} from './scmPlatformFeatures'; @@ -49,29 +46,37 @@ jest.mock('sentry/data/platforms', () => { }; }); -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); - }; -} - const mockRepository = RepositoryFixture({ id: '42', provider: {id: 'integrations:github', name: 'GitHub'}, }); +const defaultProps = { + onComplete: jest.fn(), + onPlatformChange: jest.fn(), + onFeaturesChange: jest.fn(), + selectedPlatform: undefined as OnboardingSelectedSDK | undefined, + selectedFeatures: undefined as ProductSolution[] | undefined, + selectedRepository: undefined as Repository | undefined, +}; + describe('ScmPlatformFeatures', () => { const organization = OrganizationFixture({ features: ['performance-view', 'session-replay', 'profiling-view'], }); + function renderComponent( + overrides?: Partial, + orgOverride?: typeof organization + ) { + const props = {...defaultProps, ...overrides}; + return render(, { + organization: orgOverride ?? organization, + }); + } + beforeEach(() => { jest.clearAllMocks(); - sessionStorageWrapper.clear(); }); afterEach(() => { @@ -93,19 +98,7 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); expect(await screen.findByText('Next.js')).toBeInTheDocument(); expect(screen.getByText('Django')).toBeInTheDocument(); @@ -126,19 +119,7 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); expect(await screen.findByText('What do you want to set up?')).toBeInTheDocument(); }); @@ -158,19 +139,7 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); const changeButton = await screen.findByRole('button', { name: "Doesn't look right? Change platform", @@ -187,19 +156,7 @@ describe('ScmPlatformFeatures', () => { body: {detail: 'Internal Error'}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); expect( await screen.findByRole('heading', {name: 'Select a platform'}) @@ -208,17 +165,7 @@ describe('ScmPlatformFeatures', () => { }); it('renders manual picker when no repository in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + renderComponent(); expect( await screen.findByRole('heading', {name: 'Select a platform'}) @@ -227,17 +174,7 @@ describe('ScmPlatformFeatures', () => { }); it('continue button is disabled when no platform selected', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + renderComponent(); // Wait for the component to fully settle (CompactSelect triggers async popper updates) await screen.findByRole('heading', {name: 'Select a platform'}); @@ -253,19 +190,7 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); // Wait for auto-select of first detected platform await waitFor(() => { @@ -284,20 +209,13 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + const onFeaturesChange = jest.fn(); + + renderComponent({ + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + onFeaturesChange, + }); // Wait for feature cards to appear await screen.findByText('What do you want to set up?'); @@ -309,24 +227,20 @@ describe('ScmPlatformFeatures', () => { // Enable profiling — tracing should auto-enable await userEvent.click(screen.getByRole('checkbox', {name: /Profiling/})); - expect(screen.getByRole('checkbox', {name: /Profiling/})).toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Tracing/})).toBeChecked(); + // The component calls onFeaturesChange with both profiling and tracing enabled + expect(onFeaturesChange).toHaveBeenCalledWith( + expect.arrayContaining([ + ProductSolution.ERROR_MONITORING, + ProductSolution.PROFILING, + ProductSolution.PERFORMANCE_MONITORING, + ]) + ); }); it('shows framework suggestion modal when selecting a base language', async () => { const mockOpenModal = openModal as jest.Mock; - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + renderComponent(); await screen.findByRole('heading', {name: 'Select a platform'}); @@ -342,19 +256,12 @@ describe('ScmPlatformFeatures', () => { it('opens console modal when selecting a disabled gaming platform', async () => { const mockOpenConsoleModal = openConsoleModal as jest.Mock; - render( - null} - />, - { - // No enabledConsolePlatforms — all console platforms are blocked - organization: OrganizationFixture({ - features: ['performance-view', 'session-replay', 'profiling-view'], - }), - additionalWrapper: makeOnboardingWrapper(), - } + renderComponent( + {}, + // No enabledConsolePlatforms — all console platforms are blocked + OrganizationFixture({ + features: ['performance-view', 'session-replay', 'profiling-view'], + }) ); await screen.findByRole('heading', {name: 'Select a platform'}); @@ -379,32 +286,25 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [pythonPlatform]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedPlatform: { - key: 'python', - name: 'Python', - language: 'python', - type: 'language', - link: 'https://docs.sentry.io/platforms/python/', - category: 'popular', - }, - selectedFeatures: [ - ProductSolution.ERROR_MONITORING, - ProductSolution.PERFORMANCE_MONITORING, - ProductSolution.PROFILING, - ], - }), - } - ); + const onFeaturesChange = jest.fn(); + + renderComponent({ + selectedRepository: mockRepository, + selectedPlatform: { + key: 'python', + name: 'Python', + language: 'python', + type: 'language', + link: 'https://docs.sentry.io/platforms/python/', + category: 'popular', + }, + selectedFeatures: [ + ProductSolution.ERROR_MONITORING, + ProductSolution.PERFORMANCE_MONITORING, + ProductSolution.PROFILING, + ], + onFeaturesChange, + }); // Wait for feature cards to appear await screen.findByText('What do you want to set up?'); @@ -416,8 +316,8 @@ describe('ScmPlatformFeatures', () => { // Disable tracing — profiling should auto-disable await userEvent.click(screen.getByRole('checkbox', {name: /Tracing/})); - expect(screen.getByRole('checkbox', {name: /Tracing/})).not.toBeChecked(); - expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); + // The component calls onFeaturesChange with both tracing and profiling removed + expect(onFeaturesChange).toHaveBeenCalledWith([ProductSolution.ERROR_MONITORING]); }); describe('analytics', () => { @@ -428,17 +328,7 @@ describe('ScmPlatformFeatures', () => { }); it('fires step viewed event on mount', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + renderComponent(); await screen.findByRole('heading', {name: 'Select a platform'}); @@ -463,19 +353,7 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); // Wait for detected platforms, then click the second one const djangoCard = await screen.findByRole('radio', {name: /Django/}); @@ -498,20 +376,10 @@ describe('ScmPlatformFeatures', () => { }, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - selectedFeatures: [ProductSolution.ERROR_MONITORING], - }), - } - ); + renderComponent({ + selectedRepository: mockRepository, + selectedFeatures: [ProductSolution.ERROR_MONITORING], + }); await screen.findByText('What do you want to set up?'); @@ -533,19 +401,7 @@ describe('ScmPlatformFeatures', () => { body: {platforms: [DetectedPlatformFixture()]}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedRepository: mockRepository, - }), - } - ); + renderComponent({selectedRepository: mockRepository}); const changeButton = await screen.findByRole('button', { name: "Doesn't look right? Change platform", diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index 1ff89a1926c614..77e1ace0bc670c 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -11,13 +11,13 @@ import {closeModal, openConsoleModal, openModal} from 'sentry/actionCreators/mod import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import { getDisabledProducts, platformProductAvailability, } from 'sentry/components/onboarding/productSelection'; import {platforms} from 'sentry/data/platforms'; import {t} from 'sentry/locale'; +import type {Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {PlatformIntegration, PlatformKey} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -34,7 +34,6 @@ import { useScmPlatformDetection, type DetectedPlatform, } from './components/useScmPlatformDetection'; -import type {StepProps} from './types'; interface ResolvedPlatform extends DetectedPlatform { info: PlatformIntegration; @@ -76,15 +75,24 @@ function shouldSuggestFramework(platformKey: PlatformKey): boolean { // Wider than SCM_STEP_CONTENT_WIDTH (506px) used by the footer. const PLATFORM_CONTENT_WIDTH = '564px'; -export function ScmPlatformFeatures({onComplete}: StepProps) { +interface ScmPlatformFeaturesProps { + onComplete: () => void; + onFeaturesChange: (features: ProductSolution[]) => void; + onPlatformChange: (platform: OnboardingSelectedSDK | undefined) => void; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; + selectedRepository: Repository | undefined; +} + +export function ScmPlatformFeatures({ + onComplete, + onFeaturesChange, + onPlatformChange, + selectedFeatures, + selectedPlatform, + selectedRepository, +}: ScmPlatformFeaturesProps) { const organization = useOrganization(); - const { - selectedRepository, - selectedPlatform, - setSelectedPlatform, - selectedFeatures, - setSelectedFeatures, - } = useOnboardingContext(); const [showManualPicker, setShowManualPicker] = useState(false); @@ -96,10 +104,10 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { (platformKey: PlatformKey) => { const info = getPlatformInfo(platformKey); if (info) { - setSelectedPlatform(toSelectedSdk(info)); + onPlatformChange(toSelectedSdk(info)); } }, - [setSelectedPlatform] + [onPlatformChange] ); const hasScmConnected = !!selectedRepository; @@ -178,7 +186,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { } } - setSelectedFeatures(Array.from(newFeatures)); + onFeaturesChange(Array.from(newFeatures)); trackAnalytics('onboarding.scm_platform_feature_toggled', { organization, @@ -189,7 +197,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { }, [ currentFeatures, - setSelectedFeatures, + onFeaturesChange, disabledProducts, availableFeatures, organization, @@ -199,10 +207,10 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { const applyPlatformSelection = useCallback( (sdk: OnboardingSelectedSDK) => { - setSelectedPlatform(sdk); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + onPlatformChange(sdk); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); }, - [setSelectedPlatform, setSelectedFeatures] + [onPlatformChange, onFeaturesChange] ); const handleManualPlatformSelect = useCallback( @@ -270,7 +278,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -281,7 +289,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { [ selectedPlatform?.key, setPlatform, - setSelectedFeatures, + onFeaturesChange, applyPlatformSelection, organization, ] @@ -293,7 +301,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { return; } setPlatform(platformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -301,7 +309,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { source: 'detected', }); }, - [selectedPlatform?.key, setPlatform, setSelectedFeatures, organization] + [selectedPlatform?.key, setPlatform, onFeaturesChange, organization] ); function handleChangePlatformClick() { @@ -317,7 +325,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { setShowManualPicker(false); if (detectedPlatformKey) { setPlatform(detectedPlatformKey); - setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + onFeaturesChange([ProductSolution.ERROR_MONITORING]); } } @@ -327,7 +335,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { setPlatform(currentPlatformKey); } if (!selectedFeatures) { - setSelectedFeatures(currentFeatures); + onFeaturesChange(currentFeatures); } onComplete(); } diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index c0a38f35ea065a..e0ddc6945c42bf 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -1,31 +1,16 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {RepositoryFixture} from 'sentry-fixture/repository'; import {TeamFixture} from 'sentry-fixture/team'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - OnboardingContextProvider, - type OnboardingSessionState, -} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; import {TeamStore} from 'sentry/stores/teamStore'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import * as analytics from 'sentry/utils/analytics'; -import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; import {ScmProjectDetails} from './scmProjectDetails'; -function makeOnboardingWrapper(initialState?: OnboardingSessionState) { - return function OnboardingWrapper({children}: {children?: React.ReactNode}) { - return ( - - {children} - - ); - }; -} - const mockPlatform: OnboardingSelectedSDK = { key: 'javascript-nextjs', name: 'Next.js', @@ -35,14 +20,23 @@ const mockPlatform: OnboardingSelectedSDK = { type: 'framework', }; -const mockRepository = RepositoryFixture({id: '42', name: 'getsentry/sentry'}); - describe('ScmProjectDetails', () => { const organization = OrganizationFixture(); const teamWithAccess = TeamFixture({slug: 'my-team', access: ['team:admin']}); + const defaultProps = { + onComplete: jest.fn(), + onProjectCreated: jest.fn(), + selectedPlatform: mockPlatform as OnboardingSelectedSDK | undefined, + selectedFeatures: undefined as ProductSolution[] | undefined, + }; + + function renderComponent(overrides?: Partial) { + const props = {...defaultProps, ...overrides}; + return render(, {organization}); + } + beforeEach(() => { - sessionStorageWrapper.clear(); TeamStore.loadInitialData([teamWithAccess]); // useCreateNotificationAction queries messaging integrations on mount @@ -64,37 +58,13 @@ describe('ScmProjectDetails', () => { }); it('renders step header with heading', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); expect(await screen.findByText('Project details')).toBeInTheDocument(); }); it('renders section headers with icons', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); expect(await screen.findByText('Give your project a name')).toBeInTheDocument(); expect(screen.getByText('Assign a team')).toBeInTheDocument(); @@ -103,76 +73,29 @@ describe('ScmProjectDetails', () => { }); it('renders project name defaulted from platform key', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('javascript-nextjs'); }); - it('uses platform key as default name even when repository is in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - selectedRepository: mockRepository, - }), - } - ); + it('uses platform key as default name even when repository was connected', async () => { + renderComponent(); const input = await screen.findByPlaceholderText('project-name'); expect(input).toHaveValue('javascript-nextjs'); }); it('renders card-style alert frequency options', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); expect(await screen.findByText('High priority issues')).toBeInTheDocument(); expect(screen.getByText('Custom')).toBeInTheDocument(); expect(screen.getByText("I'll create my own alerts later")).toBeInTheDocument(); }); - it('create project button is disabled without platform in context', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper(), - } - ); + it('create project button is disabled without platform', async () => { + renderComponent({selectedPlatform: undefined}); expect(await screen.findByRole('button', {name: 'Create project'})).toBeDisabled(); }); @@ -200,19 +123,7 @@ describe('ScmProjectDetails', () => { body: [teamWithAccess], }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent({onComplete}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); @@ -227,25 +138,13 @@ describe('ScmProjectDetails', () => { }); it('defaults team selector to first admin team', async () => { - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); // TeamSelector renders the team slug as the selected value expect(await screen.findByText(`#${teamWithAccess.slug}`)).toBeInTheDocument(); }); - it('updates context with project slug after creation', async () => { + it('calls onProjectCreated with project slug after creation', async () => { const createdProject = ProjectFixture({ slug: 'my-custom-project', name: 'my-custom-project', @@ -270,20 +169,9 @@ describe('ScmProjectDetails', () => { }); const onComplete = jest.fn(); + const onProjectCreated = jest.fn(); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent({onComplete, onProjectCreated}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); @@ -291,12 +179,7 @@ describe('ScmProjectDetails', () => { expect(onComplete).toHaveBeenCalled(); }); - // Verify the project slug was stored separately in context (not overwriting - // selectedPlatform.key) so onboarding.tsx can find the project via - // useRecentCreatedProject while preserving the original platform selection. - const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); - expect(stored.createdProjectSlug).toBe('my-custom-project'); - expect(stored.selectedPlatform?.key).toBe('javascript-nextjs'); + expect(onProjectCreated).toHaveBeenCalledWith('my-custom-project'); }); it('shows error message on project creation failure', async () => { @@ -309,19 +192,7 @@ describe('ScmProjectDetails', () => { body: {detail: 'Internal Error'}, }); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent({onComplete}); const createButton = await screen.findByRole('button', {name: 'Create project'}); await userEvent.click(createButton); @@ -334,19 +205,7 @@ describe('ScmProjectDetails', () => { it('fires step viewed analytics on mount', async () => { const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent(); await screen.findByText('Project details'); @@ -379,19 +238,7 @@ describe('ScmProjectDetails', () => { const onComplete = jest.fn(); - render( - null} - />, - { - organization, - additionalWrapper: makeOnboardingWrapper({ - selectedPlatform: mockPlatform, - }), - } - ); + renderComponent({onComplete}); await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index 65741fb574b488..a49f3aa4b294f7 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -7,11 +7,12 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext'; +import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules'; import {TeamSelector} from 'sentry/components/teamSelector'; import {IconGroup, IconProject, IconSiren} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import {slugify} from 'sentry/utils/slugify'; @@ -27,14 +28,26 @@ import { import {ScmAlertFrequency} from './components/scmAlertFrequency'; import {ScmStepFooter} from './components/scmStepFooter'; import {ScmStepHeader} from './components/scmStepHeader'; -import type {StepProps} from './types'; const PROJECT_DETAILS_WIDTH = '285px'; -export function ScmProjectDetails({onComplete}: StepProps) { +interface ScmProjectDetailsProps { + onComplete: ( + platform?: OnboardingSelectedSDK, + query?: Record + ) => void; + onProjectCreated: (slug: string) => void; + selectedFeatures: ProductSolution[] | undefined; + selectedPlatform: OnboardingSelectedSDK | undefined; +} + +export function ScmProjectDetails({ + onComplete, + onProjectCreated, + selectedFeatures, + selectedPlatform, +}: ScmProjectDetailsProps) { const organization = useOrganization(); - const {selectedPlatform, selectedFeatures, setCreatedProjectSlug} = - useOnboardingContext(); const {teams} = useTeams(); const createProjectAndRules = useCreateProjectAndRules(); useEffect(() => { @@ -115,7 +128,7 @@ export function ScmProjectDetails({onComplete}: StepProps) { // Store the project slug separately so onboarding.tsx can find // the project via useRecentCreatedProject without corrupting // selectedPlatform.key (which the platform features step needs). - setCreatedProjectSlug(project.slug); + onProjectCreated(project.slug); trackAnalytics('onboarding.scm_project_details_create_succeeded', { organization,