Skip to content

Commit eb70229

Browse files
committed
feat(onboarding): Wire ScmProjectDetailsCore into single-view project creation
Replace the project-details placeholder in the SCM-first project-creation wizard with the project-details form, completing the single-view create flow. - Drive the Project details section with useScmProjectDetails and the presentational ScmProjectDetailsCore. - Render the Create CTA as an always-present page-level footer, so the primary action is available regardless of which steps are revealed (disabled until a platform and details are ready), alongside ProjectCreationErrorAlert. - Persist the project-details form to the wizard session state on every change (persistFormOnChange) so navigating away and back restores it, matching the repository/platform/feature selections. Cleared when the platform or repository changes. Onboarding keeps its snapshot-on-create model, whose reuse check relies on it. - On success, navigate to the new project's getting-started page and clear the wizard session. - Pass allowMemberWithoutTeam so a member with no admin team can still create a project with no team, matching classic project creation. Messaging-integration alerts remain out of scope (VDY-50). Refs VDY-76
1 parent 61db678 commit eb70229

4 files changed

Lines changed: 384 additions & 49 deletions

File tree

static/app/views/onboarding/components/useScmProjectDetails.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {t} from 'sentry/locale';
88
import type {Repository} from 'sentry/types/integrations';
99
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
1010
import type {Team} from 'sentry/types/organization';
11+
import type {Project} from 'sentry/types/project';
1112
import {trackAnalytics} from 'sentry/utils/analytics';
1213
import {fetchMutation} from 'sentry/utils/queryClient';
1314
import type {RequestError} from 'sentry/utils/requestError/requestError';
@@ -56,7 +57,8 @@ interface UseScmProjectDetailsOptions {
5657
analyticsFlow: ScmAnalyticsFlow;
5758
/** Called after a project is created (or an unchanged one reused). */
5859
onComplete: () => void;
59-
onProjectCreated: (slug: string | undefined) => void;
60+
/** Called with the freshly created project (not called on the reuse path). */
61+
onProjectCreated: (project: Project) => void;
6062
onProjectDetailsFormChange: (form: ProjectDetailsFormState) => void;
6163
selectedPlatform: OnboardingSelectedSDK | undefined;
6264
selectedRepository: Repository | undefined;
@@ -237,9 +239,9 @@ export function useScmProjectDetails({
237239
createNotificationAction: () => {},
238240
});
239241

240-
// Store the project slug separately so the host can find the project
241-
// without corrupting selectedPlatform.key.
242-
onProjectCreated(project.slug);
242+
// Hand the created project to the host (slug for navigation, id for the
243+
// return-from-getting-started match) without corrupting selectedPlatform.
244+
onProjectCreated(project);
243245

244246
if (selectedRepository?.id) {
245247
try {

static/app/views/onboarding/scmProjectDetails.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function ScmProjectDetails({
4444
selectedRepository,
4545
createdProjectSlug,
4646
projectDetailsForm,
47-
onProjectCreated,
47+
onProjectCreated: project => onProjectCreated(project.slug),
4848
onProjectDetailsFormChange,
4949
onComplete: () =>
5050
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined),
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {ProjectFixture} from 'sentry-fixture/project';
3+
import {TeamFixture} from 'sentry-fixture/team';
4+
5+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
6+
7+
import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext';
8+
import {ProjectsStore} from 'sentry/stores/projectsStore';
9+
import {TeamStore} from 'sentry/stores/teamStore';
10+
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
11+
import {DEFAULT_ISSUE_ALERT_OPTIONS_VALUES} from 'sentry/views/projectInstall/issueAlertOptions';
12+
13+
import {ScmCreateProject} from './scmCreateProject';
14+
15+
// Mock the virtualizer so the platform-features manual-picker Select renders.
16+
jest.mock('@tanstack/react-virtual', () => ({
17+
useVirtualizer: jest.fn(({count}) => ({
18+
getVirtualItems: () =>
19+
Array.from({length: count}, (_, i) => ({
20+
key: i,
21+
index: i,
22+
start: i * 36,
23+
size: 36,
24+
})),
25+
getTotalSize: () => count * 36,
26+
measureElement: jest.fn(),
27+
})),
28+
}));
29+
30+
jest.mock('sentry/data/platforms', () => {
31+
const actual = jest.requireActual('sentry/data/platforms');
32+
return {
33+
...actual,
34+
platforms: actual.platforms.filter(
35+
(p: {id: string}) => p.id === 'python' || p.id === 'javascript'
36+
),
37+
};
38+
});
39+
40+
const WIZARD_KEY = 'project-creation-wizard';
41+
const CREATED_PROJECT_ID = 'created-1';
42+
43+
const pythonPlatform: OnboardingSelectedSDK = {
44+
key: 'python',
45+
name: 'Python',
46+
language: 'python',
47+
type: 'language',
48+
link: 'https://docs.sentry.io/platforms/python/',
49+
category: 'popular',
50+
};
51+
52+
describe('ScmCreateProject', () => {
53+
const organization = OrganizationFixture();
54+
const adminTeam = TeamFixture({slug: 'admin-team', access: ['team:admin']});
55+
56+
// Seed a persisted wizard advanced to the revealed/project-selected state, as
57+
// if the user had created a project in this session.
58+
function persistRevealedWizard(overrides: Partial<Record<string, unknown>> = {}) {
59+
window.sessionStorage.setItem(
60+
WIZARD_KEY,
61+
JSON.stringify({
62+
repoStepCompleted: true,
63+
selectedPlatform: pythonPlatform,
64+
createdProjectId: CREATED_PROJECT_ID,
65+
...overrides,
66+
})
67+
);
68+
}
69+
70+
// A return from getting-started for the created project: referrer + matching id.
71+
const returningRouterConfig = {
72+
location: {
73+
pathname: '/organizations/org-slug/projects/new/',
74+
query: {referrer: 'getting-started', project: CREATED_PROJECT_ID},
75+
},
76+
};
77+
78+
beforeEach(() => {
79+
TeamStore.reset();
80+
TeamStore.loadInitialData([adminTeam]);
81+
ProjectsStore.loadInitialData([]);
82+
83+
MockApiClient.addMockResponse({
84+
url: `/organizations/${organization.slug}/config/integrations/`,
85+
body: {providers: []},
86+
});
87+
MockApiClient.addMockResponse({
88+
url: `/organizations/${organization.slug}/integrations/`,
89+
body: [],
90+
});
91+
MockApiClient.addMockResponse({
92+
url: `/organizations/${organization.slug}/user-teams/`,
93+
body: [adminTeam],
94+
});
95+
});
96+
97+
afterEach(() => {
98+
MockApiClient.clearMockResponses();
99+
window.sessionStorage.clear();
100+
jest.clearAllMocks();
101+
});
102+
103+
it('keeps the Create CTA available (disabled) before any steps are revealed', async () => {
104+
render(<ScmCreateProject />, {organization});
105+
106+
expect(
107+
screen.queryByRole('heading', {name: 'Project details'})
108+
).not.toBeInTheDocument();
109+
const createButton = await screen.findByRole('button', {name: 'Create project'});
110+
expect(createButton).toBeDisabled();
111+
});
112+
113+
it('resets a persisted wizard on a fresh visit (no return from getting-started)', async () => {
114+
persistRevealedWizard();
115+
116+
// No referrer/project query: not a return, so the persisted state is dropped.
117+
render(<ScmCreateProject />, {organization});
118+
119+
await screen.findByRole('button', {name: 'Create project'});
120+
expect(
121+
screen.queryByRole('heading', {name: 'Project details'})
122+
).not.toBeInTheDocument();
123+
});
124+
125+
it('restores the wizard on a valid return from getting-started', async () => {
126+
const projectDetailsForm: ProjectDetailsFormState = {
127+
projectName: 'my-restored-name',
128+
teamSlug: adminTeam.slug,
129+
};
130+
persistRevealedWizard({projectDetailsForm});
131+
132+
render(<ScmCreateProject />, {
133+
organization,
134+
initialRouterConfig: returningRouterConfig,
135+
});
136+
137+
expect(
138+
await screen.findByRole('heading', {name: 'Project details'})
139+
).toBeInTheDocument();
140+
expect(screen.getByPlaceholderText('project-name')).toHaveValue('my-restored-name');
141+
});
142+
143+
it('navigates to the new project getting-started on creation', async () => {
144+
persistRevealedWizard();
145+
146+
const createRequest = MockApiClient.addMockResponse({
147+
url: `/teams/${organization.slug}/${adminTeam.slug}/projects/`,
148+
method: 'POST',
149+
body: ProjectFixture({slug: 'python', name: 'python'}),
150+
});
151+
MockApiClient.addMockResponse({
152+
url: `/organizations/${organization.slug}/`,
153+
body: organization,
154+
});
155+
MockApiClient.addMockResponse({
156+
url: `/organizations/${organization.slug}/projects/`,
157+
body: [],
158+
});
159+
MockApiClient.addMockResponse({
160+
url: `/organizations/${organization.slug}/teams/`,
161+
body: [adminTeam],
162+
});
163+
164+
const {router} = render(<ScmCreateProject />, {
165+
organization,
166+
initialRouterConfig: returningRouterConfig,
167+
});
168+
169+
await userEvent.click(await screen.findByRole('button', {name: 'Create project'}));
170+
171+
await waitFor(() => {
172+
expect(createRequest).toHaveBeenCalled();
173+
});
174+
await waitFor(() => {
175+
expect(router.location.pathname).toContain('/python/getting-started/');
176+
});
177+
});
178+
179+
it('reuses the existing project on an unchanged return instead of duplicating', async () => {
180+
ProjectsStore.loadInitialData([
181+
ProjectFixture({slug: 'python', name: 'python', platform: 'python'}),
182+
]);
183+
persistRevealedWizard({
184+
createdProjectSlug: 'python',
185+
projectDetailsForm: {
186+
projectName: 'python',
187+
teamSlug: adminTeam.slug,
188+
alertRuleConfig: DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
189+
},
190+
});
191+
192+
const createRequest = MockApiClient.addMockResponse({
193+
url: `/teams/${organization.slug}/${adminTeam.slug}/projects/`,
194+
method: 'POST',
195+
body: ProjectFixture({slug: 'python', name: 'python'}),
196+
});
197+
198+
const {router} = render(<ScmCreateProject />, {
199+
organization,
200+
initialRouterConfig: returningRouterConfig,
201+
});
202+
203+
await userEvent.click(await screen.findByRole('button', {name: 'Create project'}));
204+
205+
await waitFor(() => {
206+
expect(router.location.pathname).toContain('/python/getting-started/');
207+
});
208+
expect(createRequest).not.toHaveBeenCalled();
209+
});
210+
});

0 commit comments

Comments
 (0)