diff --git a/static/app/views/onboarding/components/scmProjectDetailsCore.tsx b/static/app/views/onboarding/components/scmProjectDetailsCore.tsx index 9816d4f863ef4a..99e6b2a293dddb 100644 --- a/static/app/views/onboarding/components/scmProjectDetailsCore.tsx +++ b/static/app/views/onboarding/components/scmProjectDetailsCore.tsx @@ -67,7 +67,7 @@ export function ScmProjectDetailsCore({ return ( - + @@ -86,7 +86,7 @@ export function ScmProjectDetailsCore({ {!isOrgMemberWithNoAccess && ( - + @@ -108,7 +108,7 @@ export function ScmProjectDetailsCore({ )} - + @@ -117,7 +117,7 @@ export function ScmProjectDetailsCore({ - + {t('Get notified when things go wrong')} diff --git a/static/app/views/projectInstall/scmCreateProject.spec.tsx b/static/app/views/projectInstall/scmCreateProject.spec.tsx new file mode 100644 index 00000000000000..c90a12107b40d6 --- /dev/null +++ b/static/app/views/projectInstall/scmCreateProject.spec.tsx @@ -0,0 +1,242 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {TeamFixture} from 'sentry-fixture/team'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {TeamStore} from 'sentry/stores/teamStore'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import {DEFAULT_ISSUE_ALERT_OPTIONS_VALUES} from 'sentry/views/projectInstall/issueAlertOptions'; + +import {ScmCreateProject} from './scmCreateProject'; + +// Mock the virtualizer so the platform-features manual-picker Select renders. +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: jest.fn(({count}) => ({ + getVirtualItems: () => + Array.from({length: count}, (_, i) => ({ + key: i, + index: i, + start: i * 36, + size: 36, + })), + getTotalSize: () => count * 36, + measureElement: jest.fn(), + })), +})); + +jest.mock('sentry/data/platforms', () => { + const actual = jest.requireActual('sentry/data/platforms'); + return { + ...actual, + platforms: actual.platforms.filter( + (p: {id: string}) => p.id === 'python' || p.id === 'javascript' + ), + }; +}); + +const WIZARD_KEY = 'project-creation-wizard'; +const CREATED_PROJECT_ID = 'created-1'; + +const pythonPlatform: OnboardingSelectedSDK = { + key: 'python', + name: 'Python', + language: 'python', + type: 'language', + link: 'https://docs.sentry.io/platforms/python/', + category: 'popular', +}; + +describe('ScmCreateProject', () => { + const organization = OrganizationFixture(); + const adminTeam = TeamFixture({slug: 'admin-team', access: ['team:admin']}); + + // Seed a persisted wizard advanced to the revealed/project-selected state, as + // if the user had created a project in this session. + function persistRevealedWizard(overrides: Partial> = {}) { + window.sessionStorage.setItem( + WIZARD_KEY, + JSON.stringify({ + repoStepCompleted: true, + selectedPlatform: pythonPlatform, + createdProjectId: CREATED_PROJECT_ID, + ...overrides, + }) + ); + } + + // A return from getting-started for the created project: referrer + matching id. + const returningRouterConfig = { + location: { + pathname: '/organizations/org-slug/projects/new/', + query: {referrer: 'getting-started', project: CREATED_PROJECT_ID}, + }, + }; + + beforeEach(() => { + TeamStore.reset(); + TeamStore.loadInitialData([adminTeam]); + ProjectsStore.loadInitialData([]); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/config/integrations/`, + body: {providers: []}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/user-teams/`, + body: [adminTeam], + }); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('keeps the Create CTA available (disabled) before any steps are revealed', async () => { + render(, {organization}); + + expect( + screen.queryByRole('heading', {name: 'Project details'}) + ).not.toBeInTheDocument(); + const createButton = await screen.findByRole('button', {name: 'Create project'}); + expect(createButton).toBeDisabled(); + }); + + it('resets a persisted wizard on a fresh visit (no return from getting-started)', async () => { + persistRevealedWizard(); + + // No referrer/project query: not a return, so the persisted state is dropped. + render(, {organization}); + + await screen.findByRole('button', {name: 'Create project'}); + expect( + screen.queryByRole('heading', {name: 'Project details'}) + ).not.toBeInTheDocument(); + }); + + it('restores the wizard on a valid return from getting-started', async () => { + const projectDetailsForm: ProjectDetailsFormState = { + projectName: 'my-restored-name', + teamSlug: adminTeam.slug, + }; + persistRevealedWizard({projectDetailsForm}); + + render(, { + organization, + initialRouterConfig: returningRouterConfig, + }); + + expect( + await screen.findByRole('heading', {name: 'Project details'}) + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText('project-name')).toHaveValue('my-restored-name'); + }); + + it('restores the wizard when the return params arrive after mount', async () => { + const projectDetailsForm: ProjectDetailsFormState = { + projectName: 'my-restored-name', + teamSlug: adminTeam.slug, + }; + persistRevealedWizard({projectDetailsForm}); + + // The back nav from getting-started can land here bare before its replace + // navigation appends the referrer/project params (see ScmCreateProject). + const {router} = render(, { + organization, + initialRouterConfig: { + location: {pathname: '/organizations/org-slug/projects/new/'}, + }, + }); + + await screen.findByRole('button', {name: 'Create project'}); + expect( + screen.queryByRole('heading', {name: 'Project details'}) + ).not.toBeInTheDocument(); + + router.navigate( + `/organizations/org-slug/projects/new/?referrer=getting-started&project=${CREATED_PROJECT_ID}`, + {replace: true} + ); + + expect( + await screen.findByRole('heading', {name: 'Project details'}) + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText('project-name')).toHaveValue('my-restored-name'); + }); + + it('navigates to the new project getting-started on creation', async () => { + persistRevealedWizard(); + + const createRequest = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${adminTeam.slug}/projects/`, + method: 'POST', + body: ProjectFixture({slug: 'python', name: 'python'}), + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [adminTeam], + }); + + const {router} = render(, { + organization, + initialRouterConfig: returningRouterConfig, + }); + + await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); + + await waitFor(() => { + expect(createRequest).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(router.location.pathname).toContain('/python/getting-started/'); + }); + }); + + it('reuses the existing project on an unchanged return instead of duplicating', async () => { + ProjectsStore.loadInitialData([ + ProjectFixture({slug: 'python', name: 'python', platform: 'python'}), + ]); + persistRevealedWizard({ + createdProjectSlug: 'python', + projectDetailsForm: { + projectName: 'python', + teamSlug: adminTeam.slug, + alertRuleConfig: DEFAULT_ISSUE_ALERT_OPTIONS_VALUES, + }, + }); + + const createRequest = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${adminTeam.slug}/projects/`, + method: 'POST', + body: ProjectFixture({slug: 'python', name: 'python'}), + }); + + const {router} = render(, { + organization, + initialRouterConfig: returningRouterConfig, + }); + + await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); + + await waitFor(() => { + expect(router.location.pathname).toContain('/python/getting-started/'); + }); + expect(createRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/views/projectInstall/scmCreateProject.tsx b/static/app/views/projectInstall/scmCreateProject.tsx index d42d97e1d289df..316de6c734f5a3 100644 --- a/static/app/views/projectInstall/scmCreateProject.tsx +++ b/static/app/views/projectInstall/scmCreateProject.tsx @@ -1,4 +1,4 @@ -import {Fragment, useCallback, useEffect, useRef} from 'react'; +import {Fragment, useCallback, useState} from 'react'; import {LayoutGroup, motion} from 'framer-motion'; import {Button} from '@sentry/scraps/button'; @@ -9,20 +9,41 @@ import {Heading, Text} from '@sentry/scraps/text'; import {Access} from 'sentry/components/acl/access'; import * as Layout from 'sentry/components/layouts/thirds'; import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import type {ProjectDetailsFormState} from 'sentry/components/onboarding/onboardingContext'; +import {ProjectCreationErrorAlert} from 'sentry/components/onboarding/projectCreationErrorAlert'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {IconProject} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Integration, Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import {decodeScalar} from 'sentry/utils/queryString'; import {useCanCreateProject} from 'sentry/utils/useCanCreateProject'; -import {useSessionStorage} from 'sentry/utils/useSessionStorage'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useSessionStorage, writeStorageValue} from 'sentry/utils/useSessionStorage'; import {ScmIntegrationConnect} from 'sentry/views/onboarding/components/scmIntegrationConnect'; import {ScmPlatformFeaturesCore} from 'sentry/views/onboarding/components/scmPlatformFeaturesCore'; +import {ScmProjectDetailsCore} from 'sentry/views/onboarding/components/scmProjectDetailsCore'; import {useScmPlatformDetection} from 'sentry/views/onboarding/components/useScmPlatformDetection'; +import { + type ScmProjectDetailsCompletion, + useScmProjectDetails, +} from 'sentry/views/onboarding/components/useScmProjectDetails'; import {useScmProviders} from 'sentry/views/onboarding/components/useScmProviders'; +import {makeProjectsPathname} from 'sentry/views/projects/pathname'; const CREATE_PROJECT_MAX_WIDTH = '760px'; +const WIZARD_STORAGE_KEY = 'project-creation-wizard'; interface WizardState { + // Id/slug of the project created in this wizard session. The id validates a + // return from getting-started (see the entry resolution in ScmCreateProject); + // the slug drives the getting-started navigation and the project-details + // reuse check. + createdProjectId: string | undefined; + createdProjectSlug: string | undefined; + projectDetailsForm: ProjectDetailsFormState | undefined; // Flips true on the first meaningful action in section 1 (repo selected // or "Continue without connecting a repo" clicked). Sections 2 and 3 // reveal together when this is true. Decoupled from selection state so @@ -37,6 +58,9 @@ interface WizardState { const INITIAL_STATE: WizardState = { repoStepCompleted: false, + createdProjectId: undefined, + createdProjectSlug: undefined, + projectDetailsForm: undefined, selectedFeatures: undefined, selectedIntegration: undefined, selectedPlatform: undefined, @@ -44,38 +68,51 @@ const INITIAL_STATE: WizardState = { }; export function ScmCreateProject() { - // Session-storage backed so a refresh restores how far the user has - // progressed. Separate key from new-org onboarding's 'onboarding' key. - const [ - { - repoStepCompleted, - selectedFeatures, - selectedIntegration, - selectedPlatform, - selectedRepository, - }, - setState, - ] = useSessionStorage('project-creation-wizard', INITIAL_STATE); + const location = useLocation(); + const referrer = decodeScalar(location.query.referrer); + const projectId = decodeScalar(location.query.project); - // An optimistic repo (empty id, see useScmRepoSelection) persisted by a - // refresh mid-resolution can never fetch detection and would hold the - // platform step in a permanent spinner. Drop it once on load, also clearing - // the repo-derived platform/features so section 2 doesn't show a platform - // with no connected repo (mirrors handleClearDerivedState on a repo change). - // Live in-session optimistic selections arrive after mount and keep their - // loading state. - const hadStaleRepoOnLoad = useRef(!!selectedRepository && !selectedRepository.id); - useEffect(() => { - if (hadStaleRepoOnLoad.current) { - hadStaleRepoOnLoad.current = false; - setState(s => ({ - ...s, - selectedRepository: undefined, - selectedPlatform: undefined, - selectedFeatures: undefined, - })); - } - }, [setState]); + // Snapshot of the last completed wizard session, written when a project is + // created (see handleComplete in the wizard). Restored when this mount is a + // return from that project's getting-started page, whose back nav tags the + // URL with referrer + project id (mirrors createProject's autofill + // condition). Computed reactively rather than once at mount because the tag + // can arrive late: deleting an inactive project redirects here bare before + // the back nav's replace navigation appends the query params (browser-back + // POPs race the same way). + const [savedSession] = useSessionStorage(WIZARD_STORAGE_KEY, null); + const isReturnFromGettingStarted = + referrer === 'getting-started' && + !!savedSession?.createdProjectId && + projectId === savedSession.createdProjectId; + const restoredSession = isReturnFromGettingStarted ? savedSession : null; + + // Keyed so a restore arriving after mount remounts the wizard and + // mount-seeded form state re-reads the restored session. + return ( + + ); +} + +function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) { + const organization = useOrganization(); + const navigate = useNavigate(); + + // In-memory while in progress, so a fresh visit or reload starts clean; the + // session is only persisted once a project is created. + const [wizardState, setState] = useState(initialState); + const { + repoStepCompleted, + createdProjectSlug, + projectDetailsForm, + selectedFeatures, + selectedIntegration, + selectedPlatform, + selectedRepository, + } = wizardState; const canUserCreateProject = useCanCreateProject(); // Subscribe so the parent re-renders when integration state changes inside @@ -125,20 +162,71 @@ export function ScmCreateProject() { [setState] ); - // Clear state derived from the repository when the repo changes. Platform - // and features are repo-dependent (auto-detection seeds them). VDY-76 will - // extend this to clear the project-details form too. + // Clear state derived from the repository when the repo changes. Platform, + // features, and the project-details form are repo-dependent (auto-detection + // seeds the platform, which in turn seeds the project name). const handleClearDerivedState = useCallback(() => { setState(s => ({ ...s, selectedPlatform: undefined, selectedFeatures: undefined, + projectDetailsForm: undefined, })); }, [setState]); - // Clear the project-details form when the platform changes. VDY-76 will - // wire this up to actual project-details state. - const handleClearProjectDetailsForm = useCallback(() => {}, []); + // Clear the project-details form when the platform changes, since the + // project name defaults from the platform key; the hook re-derives cleared + // fields. + const handleClearProjectDetailsForm = useCallback(() => { + setState(s => ({...s, projectDetailsForm: undefined})); + }, [setState]); + + const handleProjectDetailsFormChange = useCallback( + (projectDetailsFormState: ProjectDetailsFormState) => { + setState(s => ({...s, projectDetailsForm: projectDetailsFormState})); + }, + [setState] + ); + + // Snapshot the completed session (the created project's id validates the + // return from getting-started, the slug feeds the reuse check, and the form + // seeds the fields) so it can be restored later (see ScmCreateProject), then + // leave for the project's getting-started page. Live wizard state never + // holds the created project, so there is nothing to commit before unmount. + const handleComplete = useCallback( + ({project, projectDetailsForm: submittedForm}: ScmProjectDetailsCompletion) => { + writeStorageValue(WIZARD_STORAGE_KEY, { + ...wizardState, + // An optimistic repo (empty id, see useScmRepoSelection) can never + // fetch detection; restoring one would strand the platform section in + // a permanent spinner, so it is not worth persisting. + selectedRepository: wizardState.selectedRepository?.id + ? wizardState.selectedRepository + : undefined, + createdProjectId: project.id, + createdProjectSlug: project.slug, + projectDetailsForm: submittedForm, + }); + navigate( + makeProjectsPathname({ + path: `/${project.slug}/getting-started/`, + organization, + }) + ); + }, + [wizardState, navigate, organization] + ); + + const form = useScmProjectDetails({ + analyticsFlow: 'project-creation', + allowMemberWithoutTeam: true, + selectedPlatform, + selectedRepository, + createdProjectSlug, + projectDetailsForm, + onProjectDetailsFormChange: handleProjectDetailsFormChange, + onComplete: handleComplete, + }); const showContinueWithoutRepo = !selectedRepository && !repoStepCompleted; const showAllSteps = repoStepCompleted; @@ -234,27 +322,60 @@ export function ScmCreateProject() { onClearProjectDetailsForm={handleClearProjectDetailsForm} /> - + + + + + {t('Project details')} + + + {t('Name your project, assign a team, and set up issue alerts.')} + + + + )} + + {/* Page-level CTA: always present so the primary action is available + regardless of which steps are currently revealed. Disabled until a + platform and project details are ready. */} + + + + + + ); } -// Placeholder for VDY-76. Will be replaced with . -function ProjectDetailsSection() { - return ( - - - {t('Project details')} - - {t('Project details step content goes here (VDY-76).')} - - ); -} - const MotionStack = motion.create(Stack); const MotionFlex = motion.create(Flex);