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
2 changes: 1 addition & 1 deletion static/app/components/onboarding/onboardingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type OnboardingContextProps = {
selectedRepository?: Repository;
};

export type OnboardingSessionState = {
type OnboardingSessionState = {
createdProjectSlug?: string;
selectedFeatures?: ProductSolution[];
selectedIntegration?: Integration;
Expand Down
79 changes: 29 additions & 50 deletions static/app/views/onboarding/components/scmRepoSelector.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -26,16 +23,6 @@ jest.mock('@tanstack/react-virtual', () => ({
})),
}));

function makeOnboardingWrapper(initialState?: OnboardingSessionState) {
return function OnboardingWrapper({children}: {children?: React.ReactNode}) {
return (
<OnboardingContextProvider initialValue={initialState}>
{children}
</OnboardingContextProvider>
);
};
}

describe('ScmRepoSelector', () => {
const organization = OrganizationFixture();

Expand All @@ -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(
<ScmRepoSelector
integration={mockIntegration}
selectedRepository={selectedRepository}
onRepositoryChange={onRepositoryChange}
/>,
{organization}
);
}

it('renders search placeholder', () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
body: {repos: []},
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper(),
});
renderSelector();

expect(screen.getByText('Search repositories')).toBeInTheDocument();
});
Expand All @@ -79,10 +79,7 @@ describe('ScmRepoSelector', () => {
body: {repos: []},
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper(),
});
renderSelector();

await userEvent.click(screen.getByRole('textbox'));

Expand All @@ -100,10 +97,7 @@ describe('ScmRepoSelector', () => {
body: {detail: 'Internal Error'},
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper(),
});
renderSelector();

await userEvent.click(screen.getByRole('textbox'));

Expand All @@ -123,10 +117,7 @@ describe('ScmRepoSelector', () => {
},
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper(),
});
renderSelector();

await userEvent.click(screen.getByRole('textbox'));

Expand All @@ -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: []},
Expand All @@ -147,10 +138,7 @@ describe('ScmRepoSelector', () => {
externalSlug: 'getsentry/old-repo',
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
});
renderSelector(selectedRepo);

expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument();
});
Expand All @@ -173,18 +161,15 @@ describe('ScmRepoSelector', () => {
],
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper(),
});
renderSelector();

await userEvent.click(screen.getByRole('textbox'));
await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'}));

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: []},
Expand All @@ -195,17 +180,14 @@ describe('ScmRepoSelector', () => {
externalSlug: 'getsentry/old-repo',
});

render(<ScmRepoSelector integration={mockIntegration} />, {
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);
});
});

Expand All @@ -225,10 +207,7 @@ describe('ScmRepoSelector', () => {
},
});

render(<ScmRepoSelector integration={mockIntegration} />, {
organization,
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
});
renderSelector(selectedRepo);

await userEvent.click(screen.getByRole('textbox'));

Expand Down
19 changes: 9 additions & 10 deletions static/app/views/onboarding/components/scmRepoSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,20 +14,24 @@ 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
);

const {busy, handleSelect, handleRemove} = useScmRepoSelection({
integration,
onSelect: setSelectedRepository,
onSelect: onRepositoryChange,
reposByIdentifier,
});

Expand All @@ -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 {
Expand Down
74 changes: 71 additions & 3 deletions static/app/views/onboarding/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<ScmConnect
selectedIntegration={selectedIntegration}
selectedRepository={selectedRepository}
onIntegrationChange={setSelectedIntegration}
onRepositoryChange={handleRepositoryChange}
onComplete={onComplete}
/>
);
}

function ScmPlatformFeaturesAdapter({onComplete}: StepProps) {
const {
selectedRepository,
selectedPlatform,
setSelectedPlatform,
selectedFeatures,
setSelectedFeatures,
} = useOnboardingContext();

return (
<ScmPlatformFeatures
selectedRepository={selectedRepository}
selectedPlatform={selectedPlatform}
selectedFeatures={selectedFeatures}
onPlatformChange={setSelectedPlatform}
onFeaturesChange={setSelectedFeatures}
onComplete={onComplete}
/>
);
}

function ScmProjectDetailsAdapter({onComplete}: StepProps) {
const {selectedPlatform, selectedFeatures, setCreatedProjectSlug} =
useOnboardingContext();

return (
<ScmProjectDetails
selectedPlatform={selectedPlatform}
selectedFeatures={selectedFeatures}
onProjectCreated={setCreatedProjectSlug}
onComplete={onComplete}
/>
);
}

const scmOnboardingSteps: StepDescriptor[] = [
{
id: OnboardingStepId.WELCOME,
Expand All @@ -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',
},
{
Expand Down
Loading
Loading