From 3e8e0dcb48e9061abb1603288984ca5cf78f21d1 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 2 Oct 2026 14:49:53 -0500 Subject: [PATCH] feat(onboarding): Make SCM project creation the default Route /projects/new/ straight to the SCM project creation wizard and delete the legacy CreateProject flow along with the onboarding-scm-project-creation checks. The getting-started product sync no longer needs the flag guard; matching the session's created project id is enough. Refs VDY-235 --- static/app/router/routes.tsx | 2 +- .../projectInstall/createProject.spec.tsx | 1161 ----------------- .../views/projectInstall/createProject.tsx | 703 ---------- .../views/projectInstall/newProject.spec.tsx | 33 - .../app/views/projectInstall/newProject.tsx | 45 - .../projectInstall/scmCreateProject.spec.tsx | 2 +- .../views/projectInstall/scmCreateProject.tsx | 2 +- .../scmCreateProjectSession.spec.tsx | 44 +- .../scmCreateProjectSession.tsx | 14 +- 9 files changed, 16 insertions(+), 1990 deletions(-) delete mode 100644 static/app/views/projectInstall/createProject.spec.tsx delete mode 100644 static/app/views/projectInstall/createProject.tsx delete mode 100644 static/app/views/projectInstall/newProject.spec.tsx delete mode 100644 static/app/views/projectInstall/newProject.tsx diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 37d79de09c3a..1e93aad82342 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -1211,7 +1211,7 @@ function buildRoutes(): RouteObject[] { }, { path: 'new/', - component: make(() => import('sentry/views/projectInstall/newProject')), + component: make(() => import('sentry/views/projectInstall/scmCreateProject')), }, { path: ':projectId/', diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx deleted file mode 100644 index 7f52aba02633..000000000000 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ /dev/null @@ -1,1161 +0,0 @@ -import {AutomationFixture} from 'sentry-fixture/automations'; -import {IssueStreamDetectorFixture} from 'sentry-fixture/detectors'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; -import {TeamFixture} from 'sentry-fixture/team'; - -import {initializeOrg} from 'sentry-test/initializeOrg'; -import { - act, - render, - renderGlobalModal, - screen, - userEvent, - waitFor, -} from 'sentry-test/reactTestingLibrary'; -import {selectEvent} from 'sentry-test/selectEvent'; - -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {OrganizationStore} from 'sentry/stores/organizationStore'; -import {TeamStore} from 'sentry/stores/teamStore'; -import type {Organization} from 'sentry/types/organization'; -import * as analytics from 'sentry/utils/analytics'; -import {CreateProject} from 'sentry/views/projectInstall/createProject'; -import {RouteAnalyticsContext} from 'sentry/views/routeAnalyticsContextProvider'; - -jest.mock('sentry/actionCreators/indicator'); - -function renderFrameworkModalMockRequests({ - organization, - teamSlug, -}: { - organization: Organization; - teamSlug: string; -}) { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/teams/`, - body: [TeamFixture({slug: teamSlug})], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - body: organization, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/projects/`, - body: [], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/`, - body: [ - OrganizationIntegrationsFixture({ - name: "Moo Deng's Workspace", - }), - ], - match: [MockApiClient.matchQuery({integrationType: 'messaging'})], - }); - - const projectCreationMockRequest = MockApiClient.addMockResponse({ - url: `/teams/${organization.slug}/${teamSlug}/projects/`, - method: 'POST', - body: {slug: 'testProj'}, - }); - - const orgProjectCreationMockRequest = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/projects/`, - method: 'POST', - body: {slug: 'testProj', team: {slug: 'testTeam'}}, - }); - - return {projectCreationMockRequest, orgProjectCreationMockRequest}; -} - -describe('CreateProject', () => { - const teamNoAccess = TeamFixture({ - slug: 'test', - id: '1', - name: 'test', - access: ['team:read'], - }); - - const teamWithAccess = TeamFixture({ - access: ['team:admin', 'team:write', 'team:read'], - }); - - const integration = OrganizationIntegrationsFixture({ - name: "Moo Deng's Workspace", - }); - - beforeEach(() => { - TeamStore.reset(); - TeamStore.loadUserTeams([teamNoAccess]); - - MockApiClient.addMockResponse({ - url: '/organizations/org-slug/integrations/', - body: [integration], - match: [MockApiClient.matchQuery({integrationType: 'messaging'})], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/org-slug/integrations/${integration.id}/channels/`, - body: { - results: [], - }, - }); - }); - - afterEach(() => { - MockApiClient.clearMockResponses(); - window.sessionStorage.clear(); - }); - - function renderCreateWithOriginQuery(query: Record = {}) { - const organization = OrganizationFixture({ - access: ['project:read', 'project:write', 'project:admin'], - }); - TeamStore.loadUserTeams([teamWithAccess]); - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - const routeAnalytics = { - previousUrl: '', - setDisableRouteAnalytics: jest.fn(), - setEventNames: jest.fn(), - setOrganization: jest.fn(), - setRouteAnalyticsParams: jest.fn(), - }; - - render( - - - , - { - organization, - initialRouterConfig: { - location: { - pathname: '/organizations/org-slug/projects/new/', - query, - }, - }, - } - ); - - return {organization, routeAnalytics}; - } - - /** - * Mocks the requests made when a project is created with a messaging - * integration selected, up to the workflow request each test controls. - */ - function mockMessagingProjectCreation({ - provider, - channels, - }: { - channels: Array<{display: string; id: string; name: string; type: string}>; - provider: {key: string; name: string}; - }) { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - const messagingIntegration = OrganizationIntegrationsFixture({ - id: '338731', - name: "Moo Deng's Workspace", - provider: { - key: provider.key, - slug: provider.key, - name: provider.name, - canAdd: true, - canDisable: false, - features: ['alert-rule', 'chat-unfurl'], - aspects: { - alerts: [], - }, - }, - }); - - TeamStore.loadUserTeams([teamWithAccess]); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/teams/`, - body: [TeamFixture({slug: teamWithAccess.slug})], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - body: organization, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/`, - body: [messagingIntegration], - match: [MockApiClient.matchQuery({integrationType: 'messaging'})], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/${messagingIntegration.id}/channels/`, - body: {results: channels}, - }); - - const projectCreationMockRequest = MockApiClient.addMockResponse({ - url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, - method: 'POST', - body: {id: '1', slug: 'testProj', name: 'Test Project'}, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, - body: [IssueStreamDetectorFixture({projectId: '1'})], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/projects/`, - body: [ - { - id: '1', - slug: 'testProj', - name: 'Test Project', - }, - ], - }); - - return {organization, messagingIntegration, projectCreationMockRequest}; - } - - async function createProjectWithChannel(channelLabel: string) { - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click(screen.getByText(/When there are more than/)); - await userEvent.click( - screen.getByRole('checkbox', { - name: /Notify via integration/, - }) - ); - await selectEvent.select(screen.getByLabelText('channel'), channelLabel); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - } - - it('sets page-view origin to org_creation from the org-create seed param', () => { - const {routeAnalytics} = renderCreateWithOriginQuery({ - projectCreationOrigin: 'org_creation', - }); - - expect(routeAnalytics.setEventNames).toHaveBeenCalledWith( - 'project_creation_page.viewed', - 'Project Create: Creation page viewed' - ); - expect(routeAnalytics.setRouteAnalyticsParams).toHaveBeenCalledWith({ - variant: 'legacy', - origin: 'org_creation', - }); - }); - - it('keeps org_creation origin sticky after getting-started autofill return', () => { - // Prior org-create land already wrote sticky origin for this org. - window.sessionStorage.setItem('project-creation-origin:org-slug', 'org_creation'); - - // Back from instructions only brings autofill referrer; journey origin sticks. - const {routeAnalytics} = renderCreateWithOriginQuery({ - referrer: 'getting-started', - project: '123', - }); - expect(routeAnalytics.setRouteAnalyticsParams).toHaveBeenCalledWith({ - variant: 'legacy', - origin: 'org_creation', - }); - }); - - it('defaults page-view origin to existing_org without a seed', () => { - const {routeAnalytics} = renderCreateWithOriginQuery(); - - expect(routeAnalytics.setRouteAnalyticsParams).toHaveBeenCalledWith({ - variant: 'legacy', - origin: 'existing_org', - }); - }); - - it('does not treat getting-started referrer alone as org creation', () => { - const {routeAnalytics} = renderCreateWithOriginQuery({ - referrer: 'getting-started', - project: '123', - }); - - expect(routeAnalytics.setRouteAnalyticsParams).toHaveBeenCalledWith({ - variant: 'legacy', - origin: 'existing_org', - }); - }); - - it('should block if you have access to no teams without team-roles', () => { - const organization = OrganizationFixture({ - id: '1', - slug: 'org-slug', - access: ['project:read'], - features: [], - }); - - render(, {organization}); - }); - - it('can create a new project as member with team-roles', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - renderFrameworkModalMockRequests({organization, teamSlug: 'team-two'}); - TeamStore.loadUserTeams([TeamFixture({id: '2', slug: 'team-two', access: []})]); - - render(, { - organization, - }); - - renderGlobalModal(); - await userEvent.click(screen.getByTestId('platform-apple-ios')); - const createTeamButton = screen.queryByRole('button', {name: 'Create a team'}); - expect(createTeamButton).not.toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled(); - }); - - it('should only allow teams which the user is a team-admin', async () => { - const {organization} = initializeOrg({ - organization: { - features: ['team-roles'], - }, - }); - renderFrameworkModalMockRequests({organization, teamSlug: 'team-two'}); - - OrganizationStore.onUpdate(organization); - TeamStore.loadUserTeams([ - TeamFixture({id: '1', slug: 'team-one', access: []}), - TeamFixture({id: '2', slug: 'team-two', access: ['team:admin']}), - TeamFixture({id: '3', slug: 'team-three', access: ['team:admin']}), - ]); - render(, { - organization, - }); - - await userEvent.type(screen.getByLabelText('Select a Team'), 'team'); - expect(screen.queryByText('#team-one')).not.toBeInTheDocument(); - expect(screen.getByText('#team-two')).toBeInTheDocument(); - expect(screen.getByText('#team-three')).toBeInTheDocument(); - }); - - it('should fill in project name if its empty when platform is chosen', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - render(, { - organization, - }); - - await userEvent.click(screen.getByTestId('platform-apple-ios')); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios'); - - await userEvent.click(screen.getByTestId('platform-ruby-rails')); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('ruby-rails'); - - // but not replace it when project slug is something else: - await userEvent.clear(screen.getByPlaceholderText('project-slug')); - await userEvent.type(screen.getByPlaceholderText('project-slug'), 'another'); - - await userEvent.click(screen.getByTestId('platform-apple-ios')); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('another'); - }); - - it('should not overwrite a user-entered project name when the name happens to match the current platform key', async () => { - // Regression test: previously, the check `projectName !== platform.key` would incorrectly - // treat the name as auto-generated if it matched the current platform slug, causing a - // platform switch to overwrite a name the user explicitly typed. - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - render(, {organization}); - - // User explicitly types a name that happens to match a platform id - await userEvent.type(screen.getByPlaceholderText('project-slug'), 'apple-ios'); - - // User then selects a different platform - await userEvent.click(screen.getByTestId('platform-ruby-rails')); - - // The name they typed should be preserved, not replaced with 'ruby-rails' - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios'); - }); - - it('should preserve user-entered project slug when filter bar auto-selects a platform', async () => { - // Regression test: PlatformPicker's debounceSearch captured setPlatform from the - // initial render. After the user typed a slug, handlePlatformChange was recreated with - // the new projectName — but debounceSearch still held the stale version with - // projectName='', so auto-selection via the filter bar would wipe the user's slug. - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - jest.useFakeTimers(); - render(, {organization}); - - await userEvent.type(screen.getByPlaceholderText('project-slug'), 'my-custom-slug', { - delay: null, - }); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-slug'); - - // Type a platform name that exactly matches "Android" (triggers debounce auto-selection) - await userEvent.type(screen.getByPlaceholderText('Filter Platforms'), 'android', { - delay: null, - }); - - // Run all pending timers and flush React state updates - act(() => { - jest.runAllTimers(); - }); - - jest.useRealTimers(); - - // The user's slug must be preserved, not replaced by the auto-selected platform id - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-slug'); - }); - - it('should allow platform to fill the project name again after the user clears it', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - render(, {organization}); - - // User types a name - await userEvent.type(screen.getByPlaceholderText('project-slug'), 'my-project'); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-project'); - - // User clears the field (signals they want the platform to drive the name again) - await userEvent.clear(screen.getByPlaceholderText('project-slug')); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue(''); - - // Now selecting a platform should fill the name - await userEvent.click(screen.getByTestId('platform-apple-ios')); - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios'); - }); - - it('should preserve user-entered project slug when returning via auto-fill after delayed route replace', async () => { - // Regression test: when the user clicks "Back to Platform Selection", the browser - // POP navigation fires first (without query params), mounting the component with - // autoFill=false. Then router.replace adds ?referrer=getting-started&project=, - // transitioning autoFill to true. useRef only initializes once, so without the - // useEffect sync, hasUserModifiedProjectName stays false and platform changes - // overwrite the user's custom slug. - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - TeamStore.loadUserTeams([teamWithAccess]); - - // Simulate a previously created project stored in localStorage - window.localStorage.setItem( - 'created-project-context', - JSON.stringify({ - id: '12345', - name: 'my-custom-name', - team: teamWithAccess.slug, - platform: { - key: 'javascript-angular', - name: 'Angular', - type: 'framework', - language: 'javascript', - category: 'popular', - }, - wasNameManuallyModified: true, - }) - ); - - // Step 1: Mount WITHOUT query params (simulates the browser POP navigation) - const {router} = render(, { - organization, - initialRouterConfig: { - location: { - pathname: '/projects/new/', - }, - }, - }); - - // Step 2: Navigate WITH query params (simulates router.replace) - router.navigate('/projects/new/?referrer=getting-started&project=12345'); - - // The slug field should be auto-filled with the stored name - await waitFor(() => { - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-name'); - }); - - // Step 3: Click a different platform - await userEvent.click(screen.getByTestId('platform-apple-ios')); - - // The user's custom slug must be preserved, not replaced with 'apple-ios' - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('my-custom-name'); - - // Clean up localStorage - window.localStorage.removeItem('created-project-context'); - }); - - it('should update project slug on platform change when slug was not manually modified', async () => { - // When the user didn't manually type a slug (wasNameManuallyModified: false), - // changing the platform should update the slug to the new platform's ID. - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - TeamStore.loadUserTeams([teamWithAccess]); - - window.localStorage.setItem( - 'created-project-context', - JSON.stringify({ - id: '12345', - name: 'javascript-angular', - team: teamWithAccess.slug, - platform: { - key: 'javascript-angular', - name: 'Angular', - type: 'framework', - language: 'javascript', - category: 'popular', - }, - wasNameManuallyModified: false, - }) - ); - - const {router} = render(, { - organization, - initialRouterConfig: { - location: { - pathname: '/projects/new/', - }, - }, - }); - - router.navigate('/projects/new/?referrer=getting-started&project=12345'); - - await waitFor(() => { - expect(screen.getByPlaceholderText('project-slug')).toHaveValue( - 'javascript-angular' - ); - }); - - // Click a different platform — slug should update since user didn't manually modify it - await userEvent.click(screen.getByTestId('platform-apple-ios')); - - expect(screen.getByPlaceholderText('project-slug')).toHaveValue('apple-ios'); - - window.localStorage.removeItem('created-project-context'); - }); - - it('should display success message on proj creation', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - const frameWorkModalMockRequests = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - TeamStore.loadUserTeams([teamWithAccess]); - - const {router} = render(, { - organization, - }); - - renderGlobalModal(); - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - - expect(frameWorkModalMockRequests.projectCreationMockRequest).toHaveBeenCalledTimes( - 1 - ); - expect(addSuccessMessage).toHaveBeenCalledWith('Created project testProj'); - expect(router.location.query.projectCreationVariant).toBe('legacy'); - }); - - it('should display error message on proj creation failure', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - - const frameWorkModalMockRequests = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - frameWorkModalMockRequests.projectCreationMockRequest = MockApiClient.addMockResponse( - { - url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, - method: 'POST', - body: {slug: 'testProj'}, - statusCode: 404, - } - ); - TeamStore.loadUserTeams([teamWithAccess]); - - render(, { - organization, - }); - - renderGlobalModal(); - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - - expect(frameWorkModalMockRequests.projectCreationMockRequest).toHaveBeenCalledTimes( - 1 - ); - expect(addErrorMessage).toHaveBeenCalledWith('Failed to create project apple-ios'); - expect(trackAnalyticsSpy).toHaveBeenCalledWith( - 'project_creation.project_details_create_clicked', - expect.objectContaining({variant: 'legacy'}) - ); - expect(trackAnalyticsSpy).toHaveBeenCalledWith( - 'project_creation.project_details_create_failed', - expect.objectContaining({variant: 'legacy'}) - ); - }); - - it('should display success message when using member endpoint', async () => { - const {organization} = initializeOrg({ - organization: { - access: ['project:read'], - features: ['team-roles'], - allowMemberProjectCreation: true, - }, - }); - - const frameWorkModalMockRequests = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamNoAccess.slug, - }); - render(, { - organization, - }); - - renderGlobalModal(); - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - - expect( - frameWorkModalMockRequests.orgProjectCreationMockRequest - ).toHaveBeenCalledTimes(1); - expect(addSuccessMessage).toHaveBeenCalledWith( - 'Created testProj under new team #testTeam' - ); - }); - - it('does not render framework selection modal if vanilla js is NOT selected', async () => { - const {organization} = initializeOrg({ - organization: { - features: ['team-roles'], - access: ['project:read', 'project:write'], - allowMemberProjectCreation: true, - }, - }); - - const frameWorkModalMockRequests = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - TeamStore.loadUserTeams([teamWithAccess]); - OrganizationStore.onUpdate(organization, {replace: true}); - - render(, { - organization, - }); - - // Select the React platform - await userEvent.click(screen.getByTestId('platform-javascript-react')); - - await userEvent.type(screen.getByLabelText('Select a Team'), teamWithAccess.slug); - await userEvent.click(screen.getByText(`#${teamWithAccess.slug}`)); - - await waitFor(() => { - expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled(); - }); - - renderGlobalModal(); - - // Click on 'configure SDK' button - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - - // Modal shall not be open - expect(screen.queryByText('Do you use a framework?')).not.toBeInTheDocument(); - - expect(frameWorkModalMockRequests.projectCreationMockRequest).toHaveBeenCalled(); - }); - - it('renders framework selection modal if vanilla js is selected', async () => { - const {organization} = initializeOrg(); - const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - - const frameWorkModalMockRequests = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - TeamStore.loadUserTeams([teamWithAccess]); - OrganizationStore.onUpdate(organization, {replace: true}); - - render(, { - organization, - }); - - // Select the JavaScript platform - await userEvent.click(screen.getByTestId('platform-javascript')); - - await userEvent.type(screen.getByLabelText('Select a Team'), teamWithAccess.slug); - await userEvent.click(screen.getByText(`#${teamWithAccess.slug}`)); - - await waitFor(() => { - expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled(); - }); - - renderGlobalModal(); - - // Click on 'configure SDK' button - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - - // Modal is open - await screen.findByText('Do you use a framework?'); - - // Close modal - await userEvent.click(screen.getByRole('button', {name: 'Close Modal'})); - - expect(frameWorkModalMockRequests.projectCreationMockRequest).not.toHaveBeenCalled(); - expect(trackAnalyticsSpy).toHaveBeenCalledWith( - 'project_creation.select_framework_modal_close_button_clicked', - expect.objectContaining({variant: 'legacy'}) - ); - // No POST was attempted, so this must not land in the create-failure rate - // denominator. - expect(trackAnalyticsSpy).not.toHaveBeenCalledWith( - 'project_creation.project_details_create_clicked', - expect.anything() - ); - }); - - it('should rollback project when rule creation fails', async () => { - const {organization, projectCreationMockRequest} = mockMessagingProjectCreation({ - provider: {key: 'discord', name: 'Discord'}, - channels: [ - { - id: '1437461639900303454', - name: 'general', - display: '#general', - type: 'text', - }, - ], - }); - - const ruleCreationMockRequest = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/workflows/`, - method: 'POST', - statusCode: 400, - body: { - actionFilters: { - nonFieldErrors: ['Integration ID is required for action type discord'], - }, - }, - match: [ - (_url, options) => - options.data?.actionFilters?.[0]?.actions?.some( - (action: {type?: string}) => action.type === 'discord' - ), - ], - }); - - const projectDeletionMockRequest = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/testProj/`, - method: 'DELETE', - }); - - render(, {organization}); - - await createProjectWithChannel('#general (1437461639900303454)'); - await waitFor(() => { - expect(projectCreationMockRequest).toHaveBeenCalledTimes(1); - }); - await waitFor(() => { - expect(ruleCreationMockRequest).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(projectDeletionMockRequest).toHaveBeenCalledTimes(1); - }); - - expect( - await screen.findByText('Integration ID is required for action type discord') - ).toBeInTheDocument(); - expect(addErrorMessage).toHaveBeenCalledWith('Failed to create project apple-ios'); - expect(addErrorMessage).toHaveBeenCalledTimes(1); - }); - - it('targets an MS Teams channel picked from the list by its name', async () => { - // The backend resolves a Teams channel by name only, so the workflow must - // carry the name. - const {organization, messagingIntegration} = mockMessagingProjectCreation({ - provider: {key: 'msteams', name: 'MS Teams'}, - channels: [ - { - id: '19:abc@thread.tacv2', - name: 'General', - display: 'General', - type: 'standard', - }, - ], - }); - - const ruleCreationMockRequest = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/workflows/`, - method: 'POST', - body: AutomationFixture(), - }); - - render(, {organization}); - - await createProjectWithChannel('General'); - - await waitFor(() => { - expect(ruleCreationMockRequest).toHaveBeenCalled(); - }); - expect(ruleCreationMockRequest).toHaveBeenCalledWith( - `/organizations/${organization.slug}/workflows/`, - expect.objectContaining({ - data: expect.objectContaining({ - actionFilters: [ - expect.objectContaining({ - actions: expect.arrayContaining([ - expect.objectContaining({ - type: 'msteams', - integrationId: messagingIntegration.id, - config: expect.objectContaining({targetDisplay: 'General'}), - }), - ]), - }), - ], - }), - }) - ); - }); - - describe('Issue Alerts Options', () => { - const organization = OrganizationFixture(); - beforeEach(() => { - TeamStore.loadUserTeams([teamWithAccess]); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/?integrationType=messaging`, - body: [ - OrganizationIntegrationsFixture({ - name: "Moo Deng's Workspace", - }), - ], - }); - }); - - afterEach(() => { - MockApiClient.clearMockResponses(); - }); - - it('should enabled the submit button if and only if all the required information has been filled', async () => { - const {projectCreationMockRequest} = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - render(, {organization}); - - // We need to query for the submit button every time we want to access it - // as re-renders can create new DOM nodes - const getSubmitButton = () => screen.getByRole('button', {name: 'Create Project'}); - - expect(getSubmitButton()).toBeDisabled(); - - // Fills the project slug - await userEvent.type(screen.getByPlaceholderText('project-slug'), 'my-project'); - - // Enforce users to select a platform - await userEvent.hover(getSubmitButton()); - expect(await screen.findByText('Please select a platform')).toBeInTheDocument(); - - await userEvent.click(getSubmitButton()); - expect(projectCreationMockRequest).not.toHaveBeenCalled(); - - await userEvent.click(screen.getByTestId('platform-apple-ios')); - expect(getSubmitButton()).toBeEnabled(); - - await userEvent.click(screen.getByText(/When there are more than/)); - expect(getSubmitButton()).toBeEnabled(); - - await userEvent.clear(screen.getByTestId('range-input')); - expect(getSubmitButton()).toBeDisabled(); - - await userEvent.type(screen.getByTestId('range-input'), '2712'); - expect(getSubmitButton()).toBeEnabled(); - - await userEvent.clear(screen.getByTestId('range-input')); - expect(getSubmitButton()).toBeDisabled(); - - await userEvent.click( - screen.getByRole('checkbox', { - name: 'Notify via integration (Slack, Discord, MS Teams, etc.)', - }) - ); - expect(getSubmitButton()).toBeDisabled(); - - await userEvent.click( - screen.getByRole('radio', {name: /create my own alerts later/i}) - ); - expect(getSubmitButton()).toBeEnabled(); - - await userEvent.click(getSubmitButton()); - expect(projectCreationMockRequest).toHaveBeenCalled(); - }); - - it('fires alert_selected and alert_threshold_edited with variant=legacy', async () => { - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - - render(, {organization}); - - // Switch to custom alerts: fires the alert-selected event, and reveals the - // threshold input. - await userEvent.click(screen.getByText(/When there are more than/)); - expect(trackAnalyticsSpy).toHaveBeenCalledWith( - 'project_creation.project_details_alert_selected', - expect.objectContaining({option: 'custom', variant: 'legacy'}) - ); - - // Edit the threshold: fires the threshold-edited event. - await userEvent.type(screen.getByTestId('range-input'), '5'); - expect(trackAnalyticsSpy).toHaveBeenCalledWith( - 'project_creation.alert_threshold_edited', - expect.objectContaining({field: 'threshold', variant: 'legacy'}) - ); - }); - - it('does not fire alert_threshold_edited unless custom alerts are selected', async () => { - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - - render(, {organization}); - - // Default is high-priority. Metric/interval Selects stay interactive while - // the custom radio is unselected (their wrappers call preventDefault), so - // an edit must not count as a custom-threshold change. - await selectEvent.select(screen.getByText('occurrences of'), /users affected/); - - expect(trackAnalyticsSpy).not.toHaveBeenCalledWith( - 'project_creation.alert_threshold_edited', - expect.anything() - ); - expect(trackAnalyticsSpy).not.toHaveBeenCalledWith( - 'project_creation.project_details_alert_selected', - expect.objectContaining({option: 'custom'}) - ); - }); - - it('should disable submit button when channel validation fails and integration is selected', async () => { - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/${integration.id}/channel-validate/`, - body: {valid: false, detail: 'Channel not found'}, - }); - - render(, {organization}); - - await userEvent.click(screen.getByTestId('platform-apple-ios')); - expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled(); - await userEvent.click( - screen.getByRole('checkbox', { - name: /Notify via integration/, - }) - ); - await selectEvent.create(screen.getByLabelText('channel'), '#custom-channel', { - waitForElement: false, - createOptionText: '#custom-channel', - }); - expect(await screen.findByText('Channel not found')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Create Project'})).toBeDisabled(); - await userEvent.hover(screen.getByRole('button', {name: 'Create Project'})); - await waitFor(() => - expect(screen.getAllByText('Channel not found')).toHaveLength(2) - ); - await userEvent.click(screen.getByLabelText('Clear choices')); - await userEvent.hover(screen.getByRole('button', {name: 'Create Project'})); - expect( - await screen.findByText(/provide an integration channel/) - ).toBeInTheDocument(); - }); - - it('should NOT disable submit button when channel validation fails but integration is unchecked', async () => { - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/${integration.id}/channel-validate/`, - body: {valid: false, detail: 'Channel not found'}, - }); - - render(, {organization}); - - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click( - screen.getByRole('checkbox', { - name: /Notify via integration/, - }) - ); - await selectEvent.create(screen.getByLabelText('channel'), '#custom-channel', { - waitForElement: false, - createOptionText: '#custom-channel', - }); - expect(await screen.findByText('Channel not found')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Create Project'})).toBeDisabled(); - await userEvent.click( - screen.getByRole('checkbox', { - name: /Notify via integration/, - }) - ); - await waitFor(() => { - expect(screen.getByRole('button', {name: 'Create Project'})).toBeEnabled(); - }); - }); - - it('should show validating tooltip and disable button while validating channel', async () => { - renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/integrations/${integration.id}/channel-validate/`, - asyncDelay: new Promise(() => {}), - }); - - render(, {organization}); - await userEvent.click(screen.getByTestId('platform-apple-ios')); - await userEvent.click( - screen.getByRole('checkbox', { - name: /Notify via integration/, - }) - ); - await selectEvent.create(screen.getByLabelText('channel'), '#custom-channel', { - waitForElement: false, - createOptionText: '#custom-channel', - }); - await waitFor(() => - expect(screen.getByRole('button', {name: 'Create Project'})).toBeDisabled() - ); - await userEvent.hover(screen.getByRole('button', {name: 'Create Project'})); - expect( - await screen.findByText(/Validating integration channel/) - ).toBeInTheDocument(); - }); - - it('should create an issue alert rule by default', async () => { - const {projectCreationMockRequest} = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - render(, {organization}); - expect( - screen.getByRole('radio', {name: /Alert me on high priority issues/i}) - ).toBeChecked(); - await userEvent.click(screen.getByTestId('platform-javascript-react')); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - expect(projectCreationMockRequest).toHaveBeenCalledWith( - `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, - expect.objectContaining({ - data: { - default_rules: true, - name: 'javascript-react', - origin: 'ui', - platform: 'javascript-react', - }, - }) - ); - }); - - it('should NOT create alerts if the user opt out', async () => { - const {projectCreationMockRequest} = renderFrameworkModalMockRequests({ - organization, - teamSlug: teamWithAccess.slug, - }); - render(, {organization}); - await userEvent.click( - screen.getByRole('radio', {name: /create my own alerts later/i}) - ); - expect( - screen.getByRole('radio', {name: /create my own alerts later/i}) - ).toBeChecked(); - await userEvent.click(screen.getByTestId('platform-javascript-react')); - await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); - expect(projectCreationMockRequest).toHaveBeenCalledWith( - `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, - expect.objectContaining({ - data: { - default_rules: false, - name: 'javascript-react', - origin: 'ui', - platform: 'javascript-react', - }, - }) - ); - }); - }); -}); diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx deleted file mode 100644 index 8b9211ea9bc0..000000000000 --- a/static/app/views/projectInstall/createProject.tsx +++ /dev/null @@ -1,703 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import styled from '@emotion/styled'; -import {useQuery} from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import omit from 'lodash/omit'; -import {PlatformIcon} from 'platformicons'; - -import {Button} from '@sentry/scraps/button'; -import {Input} from '@sentry/scraps/input'; -import {Grid} from '@sentry/scraps/layout'; -import {ExternalLink} from '@sentry/scraps/link'; -import {useModal} from '@sentry/scraps/modal'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {openConsoleModal} from 'sentry/actionCreators/modal'; -import {Access} from 'sentry/components/acl/access'; -import * as Layout from 'sentry/components/layouts/thirds'; -import {List} from 'sentry/components/list'; -import {ListItem} from 'sentry/components/list/listItem'; -import {captureProjectCreationFailure} from 'sentry/components/onboarding/captureProjectCreationFailure'; -import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; -import {ProjectCreationErrorAlert} from 'sentry/components/onboarding/projectCreationErrorAlert'; -import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules'; -import type {CreatedProjectWorkflow} from 'sentry/components/onboarding/useCreateProjectWorkflow'; -import {PlatformPicker, type Platform} from 'sentry/components/platformPicker'; -import {TeamSelector} from 'sentry/components/teamSelector'; -import {categoryList} from 'sentry/data/platformPickerCategories'; -import {t, tct} from 'sentry/locale'; -import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; -import type {Team} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {isDisabledGamingPlatform} from 'sentry/utils/platform'; -import {decodeScalar} from 'sentry/utils/queryString'; -import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; -import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {slugify} from 'sentry/utils/slugify'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useCanCreateProject} from 'sentry/utils/useCanCreateProject'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useTeams} from 'sentry/utils/useTeams'; -import { - MultipleCheckboxOptions, - useCreateNotificationAction, - type IntegrationChannel, -} from 'sentry/views/projectInstall/issueAlertNotificationOptions'; -import type { - AlertRuleOptions, - RequestDataFragment, -} from 'sentry/views/projectInstall/issueAlertOptions'; -import { - getRequestDataFragment, - IssueAlertOptions, - RuleAction, -} from 'sentry/views/projectInstall/issueAlertOptions'; -import {useProjectCreationPageOrigin} from 'sentry/views/projectInstall/projectCreationOrigin'; -import {validateChannelQueryOptions} from 'sentry/views/projectInstall/useValidateChannel'; -import {makeProjectsPathname} from 'sentry/views/projects/pathname'; - -type FormData = { - projectName: string; - alertRule?: Partial; - platform?: OnboardingSelectedSDK; - team?: string; -}; - -type CreatedProject = Pick & { - platform: OnboardingSelectedSDK; - alertRule?: Partial; - notificationRule?: CreatedProjectWorkflow; - team?: string; - wasNameManuallyModified?: boolean; -}; - -function getMissingValues({ - team, - projectName, - conditions, - notificationProps, - shouldCreateRule, - shouldCreateCustomRule, - isOrgMemberWithNoAccess, - platform, -}: { - isOrgMemberWithNoAccess: boolean; - notificationProps: { - actions?: string[]; - channel?: IntegrationChannel; - }; - projectName: string; - team: string | undefined; - platform?: OnboardingSelectedSDK; -} & Partial< - Pick ->) { - return { - isMissingTeam: !isOrgMemberWithNoAccess && !team, - isMissingProjectName: projectName === '', - isMissingAlertThreshold: - shouldCreateCustomRule && - (!conditions || - conditions.length === 0 || - !conditions.every(condition => !!condition.value)), - isMissingMessagingIntegrationChannel: - shouldCreateRule && - notificationProps.actions?.includes(MultipleCheckboxOptions.INTEGRATION) && - !notificationProps.channel, - isMissingPlatform: !platform, - }; -} - -function getSubmitTooltipText({ - isMissingProjectName, - isMissingAlertThreshold, - isMissingMessagingIntegrationChannel, - isMissingPlatform, - formErrorCount, -}: ReturnType & { - formErrorCount: number; -}): string { - if (formErrorCount > 1) { - return t('Please fill out all the required fields'); - } - if (isMissingProjectName) { - return t('Please provide a project slug'); - } - if (isMissingAlertThreshold) { - return t('Please provide an alert threshold'); - } - if (isMissingMessagingIntegrationChannel) { - return t('Please provide an integration channel for alert notifications'); - } - if (isMissingPlatform) { - return t('Please select a platform'); - } - - return t('Please select a team'); -} - -export function CreateProject() { - const {openModal, visible: isModalVisible} = useModal(); - const navigate = useNavigate(); - const organization = useOrganization(); - const location = useLocation(); - const canUserCreateProject = useCanCreateProject(); - const createProjectAndRules = useCreateProjectAndRules(); - - const {teams} = useTeams(); - const accessTeams = teams.filter((team: Team) => team.access.includes('team:admin')); - const referrer = decodeScalar(location.query.referrer); - const projectId = decodeScalar(location.query.project); - const [createdProject, setCreatedProject] = useLocalStorageState( - 'created-project-context', - null - ); - const autoFill = useMemo(() => { - return referrer === 'getting-started' && projectId === createdProject?.id; - }, [referrer, projectId, createdProject?.id]); - - const notificationActionParam = useMemo(() => { - return autoFill && createdProject?.notificationRule?.actions - ? {actions: createdProject.notificationRule.actions} - : undefined; - }, [autoFill, createdProject]); - - const {getIntegrationAction, notificationProps} = useCreateNotificationAction( - notificationActionParam - ); - - const validateChannel = useQuery({ - ...validateChannelQueryOptions({ - organizationSlug: organization.slug, - channel: notificationProps.channel, - integrationId: notificationProps.integration?.id, - }), - enabled: false, - }); - const validateChannelError = - validateChannel.data?.valid === false - ? (validateChannel.data.detail ?? t('Channel not found or restricted')) - : validateChannel.error - ? t('Unexpected integration channel validation error') - : undefined; - - const defaultTeam = accessTeams?.[0]?.slug; - - const initialData: FormData = useMemo(() => { - if (autoFill && createdProject) { - return { - projectName: createdProject.name ?? '', - platform: createdProject.platform, - team: createdProject.team ?? defaultTeam, - alertRule: createdProject.alertRule, - }; - } - - return { - projectName: '', - platform: undefined, - team: defaultTeam, - }; - }, [autoFill, defaultTeam, createdProject]); - - const [formData, setFormData] = useState(initialData); - const pickerKeyRef = useRef<'create-project' | 'auto-fill'>('create-project'); - const hasUserModifiedProjectName = useRef(false); - - // Sync the ref when autoFill data becomes available. - // useRef only initializes once, but the component may first mount without - // query params (e.g. browser back fires a POP before router.replace adds them), - // so autoFill can transition from false → true after the initial render. - // When that happens we also need to populate the projectName since useState - // would have initialized with the non-autoFill (empty) value. - useEffect(() => { - if (autoFill && createdProject?.name) { - hasUserModifiedProjectName.current = createdProject.wasNameManuallyModified ?? true; - // oxlint-disable-next-line react/set-state-in-effect - setFormData(prev => ({ - ...prev, - projectName: createdProject.name ?? prev.projectName, - })); - } - }, [autoFill, createdProject?.name, createdProject?.wasNameManuallyModified]); - - const canCreateTeam = organization.access.includes('project:admin'); - const isOrgMemberWithNoAccess = accessTeams.length === 0 && !canCreateTeam; - - const alertRuleConfig = useMemo( - () => getRequestDataFragment(formData.alertRule), - [formData.alertRule] - ); - - const missingValues = getMissingValues({ - isOrgMemberWithNoAccess, - notificationProps, - projectName: formData.projectName, - team: formData.team, - shouldCreateCustomRule: alertRuleConfig.shouldCreateCustomRule, - shouldCreateRule: alertRuleConfig.shouldCreateRule, - conditions: alertRuleConfig.conditions, - platform: formData.platform, - }); - - const isNotifyingViaIntegration = - alertRuleConfig.shouldCreateRule && - notificationProps.actions?.includes(MultipleCheckboxOptions.INTEGRATION); - - const formErrorCount = [ - missingValues.isMissingPlatform, - missingValues.isMissingTeam, - missingValues.isMissingProjectName, - missingValues.isMissingAlertThreshold, - missingValues.isMissingMessagingIntegrationChannel, - isNotifyingViaIntegration && validateChannelError, - ].filter(Boolean).length; - - const submitTooltipText = - isNotifyingViaIntegration && validateChannelError - ? validateChannelError - : getSubmitTooltipText({ - ...missingValues, - formErrorCount, - }); - - const updateFormData = useCallback( - (field: K, value: FormData[K]) => { - setFormData(prev => ({ - ...prev, - [field]: value, - })); - }, - [] - ); - - useEffect(() => { - (Object.keys(initialData) as Array).forEach(key => { - if (key === 'projectName' && hasUserModifiedProjectName.current) { - return; - } - updateFormData(key, initialData[key]); - }); - }, [initialData, updateFormData]); - - useRouteAnalyticsEventNames( - 'project_creation_page.viewed', - 'Project Create: Creation page viewed' - ); - // Journey origin is sticky (sessionStorage seeded by - // ?projectCreationOrigin=org_creation from org-create). Orthogonal to - // `variant` and to `referrer=getting-started` autofill — back-from-docs - // must not reclassify an org-activation visit as existing_org. - useRouteAnalyticsParams({variant: 'legacy', origin: useProjectCreationPageOrigin()}); - - const configurePlatform = useCallback( - async ({ - selectedFramework, - platform, - projectName, - team, - alertRule, - }: {selectedFramework?: OnboardingSelectedSDK} & Omit & { - platform: OnboardingSelectedSDK; - }) => { - const selectedPlatform = selectedFramework ?? platform; - - // Not in handleProjectCreation: every path into configurePlatform goes on - // to POST, so an abandoned framework modal stays out of the denominator. - trackAnalytics('project_creation.project_details_create_clicked', { - organization, - variant: 'legacy', - }); - - try { - const {project, notificationRule, workflowIds} = - await createProjectAndRules.mutateAsync({ - projectName, - platform: selectedPlatform, - team, - alertRuleConfig, - getIntegrationAction, - }); - - trackAnalytics('project_creation_page.created', { - organization, - issue_alert: alertRuleConfig.shouldCreateCustomRule - ? 'Custom' - : alertRuleConfig.shouldCreateRule - ? 'Default' - : 'No Rule', - project_id: project.id, - platform: selectedPlatform.key, - workflow_ids: workflowIds, - notification_rule_created: !!notificationRule, - variant: 'legacy', - }); - - addSuccessMessage( - team - ? t('Created project %s', project.slug) - : t('Created %s under new team %s', project.slug, `#${project.team.slug}`) - ); - - setCreatedProject({ - id: project.id, - name: project.name, - team: project.team?.slug, - platform: selectedPlatform, - alertRule, - notificationRule, - wasNameManuallyModified: hasUserModifiedProjectName.current, - }); - - navigate({ - pathname: normalizeUrl( - makeProjectsPathname({ - path: `/${project.slug}/getting-started/`, - organization, - }) - ), - query: {projectCreationVariant: 'legacy'}, - }); - } catch (error: any) { - addErrorMessage(t('Failed to create project %s', projectName)); - - // Unfiltered, unlike captureProjectCreationFailure below: both variants - // count every caught failure, so filtering here would skew the rate. - trackAnalytics('project_creation.project_details_create_failed', { - organization, - variant: 'legacy', - }); - - captureProjectCreationFailure({ - error, - organization, - team, - accessTeams, - variant: 'legacy', - }); - } - }, - [ - organization, - setCreatedProject, - navigate, - createProjectAndRules, - getIntegrationAction, - alertRuleConfig, - accessTeams, - ] - ); - - const handleProjectCreation = useCallback( - async ({platform, ...data}: FormData) => { - // At this point, platform should be defined - // otherwise the submit button would be disabled. - if (!platform) { - return; - } - - if ( - platform.type !== 'language' || - !Object.values(SupportedLanguages).includes( - platform.language as SupportedLanguages - ) - ) { - configurePlatform({...data, platform}); - return; - } - - const {FrameworkSuggestionModal, modalCss} = - await import('sentry/components/onboarding/frameworkSuggestionModal'); - - openModal( - deps => ( - { - configurePlatform({...data, platform, selectedFramework}); - }} - onSkip={() => configurePlatform({...data, platform})} - /> - ), - { - modalCss, - onClose: () => { - trackAnalytics( - 'project_creation.select_framework_modal_close_button_clicked', - { - platform: platform.key, - organization, - variant: 'legacy', - } - ); - }, - } - ); - }, - [configurePlatform, organization, openModal] - ); - - const debounceHandleProjectCreation = useMemo( - // oxlint-disable-next-line react/refs - () => debounce(handleProjectCreation, 2000, {leading: true, trailing: false}), - [handleProjectCreation] - ); - - const handlePlatformChange = useCallback( - (value: Platform | null) => { - if (!value) { - updateFormData('platform', undefined); - return; - } - - if ( - isDisabledGamingPlatform({ - platform: value, - enabledConsolePlatforms: organization.enabledConsolePlatforms, - }) - ) { - openConsoleModal({ - organization, - selectedPlatform: { - key: value.id, - name: value.name, - type: value.type, - language: value.language, - category: value.category, - link: value.link, - }, - origin: 'project-creation', - }); - return; - } - - setFormData(prev => ({ - ...prev, - platform: {...omit(value, 'id'), key: value.id}, - projectName: hasUserModifiedProjectName.current ? prev.projectName : value.id, - })); - }, - [updateFormData, organization] - ); - - const platform = formData.platform?.key; - const defaultCategory = platform - ? categoryList.find(({platforms}) => platforms.has(platform))?.id - : 'popular'; - - // Workaround to force PlatformPicker to re-render when users go back in the flow and fields should be pre-filled. - // Without this, the selected platform might not be visible depending on the active tab. - // oxlint-disable-next-line react/refs - if (autoFill && platform && pickerKeyRef.current === 'create-project') { - // oxlint-disable-next-line react/refs - pickerKeyRef.current = 'auto-fill'; - } - - return ( - -
- - {t('Create a new project in 3 steps')} - - {tct( - 'Set up a separate project for each part of your application (for example, your API server and frontend client), to quickly pinpoint which part of your application errors are coming from. [link: Read the docs].', - { - link: ( - - ), - } - )} - - {t('Choose your platform')} - - {t('Set your alert frequency')} - { - updateFormData('alertRule', { - ...formData.alertRule, - [field]: value, - }); - if (field === 'alertSetting') { - const optionMap: Record = { - [RuleAction.DEFAULT_ALERT]: 'high_priority', - [RuleAction.CUSTOMIZED_ALERTS]: 'custom', - [RuleAction.CREATE_ALERT_LATER]: 'create_later', - }; - trackAnalytics('project_creation.project_details_alert_selected', { - organization, - option: optionMap[value as number] ?? String(value), - variant: 'legacy', - }); - } else if ( - (field === 'threshold' || field === 'metric' || field === 'interval') && - formData.alertRule?.alertSetting === RuleAction.CUSTOMIZED_ALERTS - ) { - trackAnalytics('project_creation.alert_threshold_edited', { - organization, - field, - variant: 'legacy', - }); - } - }} - /> - - {isOrgMemberWithNoAccess - ? t('Name your project') - : t('Name your project and assign it a team')} - - -
- {t('Project slug')} - - - { - const slugified = slugify(e.target.value); - // Track whether the user has intentionally set a custom name. - // Reset if they clear the field so platform selection can fill it in again. - hasUserModifiedProjectName.current = slugified !== ''; - updateFormData('projectName', slugified); - }} - /> - -
- {!isOrgMemberWithNoAccess && ( -
- {t('Team')} - - tm.access.includes('team:admin')} - minMenuHeight={240} - value={formData.team} - onChange={({value}: {value: string}) => { - updateFormData('team', value); - }} - /> - -
- )} -
- - - -
-
- {!isModalVisible && ( - - )} -
-
-
- ); -} - -const StyledListItem = styled(ListItem)` - margin: ${p => p.theme.space.xl} 0 ${p => p.theme.space.md} 0; - font-size: ${p => p.theme.font.size.xl}; -`; - -const FormLabel = styled('div')` - font-size: ${p => p.theme.font.size.xl}; - margin-bottom: ${p => p.theme.space.md}; -`; - -const ProjectNameInputWrap = styled('div')` - position: relative; -`; - -const ProjectNameInput = styled(Input)` - padding-left: calc(${p => p.theme.form.md.paddingLeft}px * 1.5 + 20px); -`; - -const StyledPlatformIcon = styled(PlatformIcon)` - position: absolute; - top: 50%; - left: ${p => p.theme.form.md.paddingLeft}px; - transform: translateY(-50%); -`; - -const TeamSelectInput = styled('div')` - display: grid; - gap: ${p => p.theme.space.md}; - grid-template-columns: 1fr min-content; - align-items: center; -`; - -const HelpText = styled('p')` - color: ${p => p.theme.tokens.content.secondary}; - max-width: 760px; -`; diff --git a/static/app/views/projectInstall/newProject.spec.tsx b/static/app/views/projectInstall/newProject.spec.tsx deleted file mode 100644 index 5326e75b381c..000000000000 --- a/static/app/views/projectInstall/newProject.spec.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import NewProject from 'sentry/views/projectInstall/newProject'; - -jest.mock('sentry/views/projectInstall/createProject', () => ({ - CreateProject: () =>
Legacy project creation
, -})); - -jest.mock('sentry/views/projectInstall/scmCreateProject', () => ({ - ScmCreateProject: () =>
SCM project creation
, -})); - -describe('NewProject', () => { - it('renders the legacy flow when SCM project creation is disabled', () => { - render(, {organization: OrganizationFixture({features: []})}); - - expect(screen.getByText('Legacy project creation')).toBeInTheDocument(); - expect(screen.queryByText('SCM project creation')).not.toBeInTheDocument(); - }); - - it('renders the SCM flow when SCM project creation is enabled', () => { - render(, { - organization: OrganizationFixture({ - features: ['onboarding-scm-project-creation'], - }), - }); - - expect(screen.getByText('SCM project creation')).toBeInTheDocument(); - expect(screen.queryByText('Legacy project creation')).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/views/projectInstall/newProject.tsx b/static/app/views/projectInstall/newProject.tsx deleted file mode 100644 index cda97c718b76..000000000000 --- a/static/app/views/projectInstall/newProject.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import styled from '@emotion/styled'; - -import {Stack} from '@sentry/scraps/layout'; - -import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {CreateProject} from './createProject'; -import {ScmCreateProject} from './scmCreateProject'; - -function NewProject() { - const organization = useOrganization(); - const hasScmProjectCreation = organization.features.includes( - 'onboarding-scm-project-creation' - ); - - if (hasScmProjectCreation) { - return ; - } - - return ( - - - -
- - - -
-
-
-
- ); -} - -const Container = styled('div')` - flex: 1; - background: ${p => p.theme.tokens.background.primary}; -`; - -const Content = styled('div')` - margin-top: ${p => p.theme.space['2xl']}; -`; - -export default NewProject; diff --git a/static/app/views/projectInstall/scmCreateProject.spec.tsx b/static/app/views/projectInstall/scmCreateProject.spec.tsx index 57f2fd334cf7..bc50481b7ab4 100644 --- a/static/app/views/projectInstall/scmCreateProject.spec.tsx +++ b/static/app/views/projectInstall/scmCreateProject.spec.tsx @@ -26,7 +26,7 @@ import type {PlatformKey} from 'sentry/types/platform'; import {DEFAULT_ISSUE_ALERT_OPTIONS_VALUES} from 'sentry/views/projectInstall/issueAlertOptions'; import {RouteAnalyticsContext} from 'sentry/views/routeAnalyticsContextProvider'; -import {ScmCreateProject} from './scmCreateProject'; +import ScmCreateProject from './scmCreateProject'; // Mock the virtualizer so the platform-features manual-picker Select renders. jest.mock('@tanstack/react-virtual', () => ({ diff --git a/static/app/views/projectInstall/scmCreateProject.tsx b/static/app/views/projectInstall/scmCreateProject.tsx index 350ee1f15100..1f77d837012c 100644 --- a/static/app/views/projectInstall/scmCreateProject.tsx +++ b/static/app/views/projectInstall/scmCreateProject.tsx @@ -58,7 +58,7 @@ const INITIAL_STATE: WizardState = { selectedRepository: undefined, }; -export function ScmCreateProject() { +export default function ScmCreateProject() { const location = useLocation(); const referrer = decodeScalar(location.query.referrer); const projectId = decodeScalar(location.query.project); diff --git a/static/app/views/projectInstall/scmCreateProjectSession.spec.tsx b/static/app/views/projectInstall/scmCreateProjectSession.spec.tsx index ceb2106d70d0..89f6451925cb 100644 --- a/static/app/views/projectInstall/scmCreateProjectSession.spec.tsx +++ b/static/app/views/projectInstall/scmCreateProjectSession.spec.tsx @@ -41,14 +41,11 @@ describe('useScmCreateProjectProductSync', () => { jest.clearAllMocks(); }); - it('returns a callback when the flag is on and the session matches the project', () => { + it('returns a callback when the session matches the project', () => { seedSession(); - const {result} = renderHookWithProviders( - () => useScmCreateProjectProductSync(project), - { - organization: {features: ['onboarding-scm-project-creation']}, - } + const {result} = renderHookWithProviders(() => + useScmCreateProjectProductSync(project) ); expect(result.current).toBeInstanceOf(Function); @@ -60,11 +57,8 @@ describe('useScmCreateProjectProductSync', () => { projectDetailsForm: {projectName: 'my-project'}, }); - const {result} = renderHookWithProviders( - () => useScmCreateProjectProductSync(project), - { - organization: {features: ['onboarding-scm-project-creation']}, - } + const {result} = renderHookWithProviders(() => + useScmCreateProjectProductSync(project) ); act(() => { @@ -87,25 +81,8 @@ describe('useScmCreateProjectProductSync', () => { it('returns undefined when the session project does not match', () => { seedSession({createdProjectId: 'different-id'}); - const {result} = renderHookWithProviders( - () => useScmCreateProjectProductSync(project), - { - organization: {features: ['onboarding-scm-project-creation']}, - } - ); - - expect(result.current).toBeUndefined(); - }); - - it('returns undefined when the SCM project creation flag is off', () => { - seedSession(); - - const {result} = renderHookWithProviders( - () => useScmCreateProjectProductSync(project), - { - // No SCM project creation feature in org features list. - organization: {features: []}, - } + const {result} = renderHookWithProviders(() => + useScmCreateProjectProductSync(project) ); expect(result.current).toBeUndefined(); @@ -113,11 +90,8 @@ describe('useScmCreateProjectProductSync', () => { it('returns undefined when no session is present', () => { // No session seeded. - const {result} = renderHookWithProviders( - () => useScmCreateProjectProductSync(project), - { - organization: {features: ['onboarding-scm-project-creation']}, - } + const {result} = renderHookWithProviders(() => + useScmCreateProjectProductSync(project) ); expect(result.current).toBeUndefined(); diff --git a/static/app/views/projectInstall/scmCreateProjectSession.tsx b/static/app/views/projectInstall/scmCreateProjectSession.tsx index 5544db1a86f8..c4ad6544e51b 100644 --- a/static/app/views/projectInstall/scmCreateProjectSession.tsx +++ b/static/app/views/projectInstall/scmCreateProjectSession.tsx @@ -5,7 +5,6 @@ import type {ProjectDetailsFormState} from 'sentry/components/onboarding/scm/scm import type {Integration, Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Project} from 'sentry/types/project'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; export const WIZARD_STORAGE_KEY = 'project-creation-wizard'; @@ -28,21 +27,16 @@ export interface WizardState { export function useScmCreateProjectProductSync( project: Project ): ((products: ProductSolution[]) => void) | undefined { - const organization = useOrganization(); - const hasScmProjectCreation = organization.features.includes( - 'onboarding-scm-project-creation' - ); - const [session, setSession] = useSessionStorage( WIZARD_STORAGE_KEY, null ); // Guard: only sync when this getting-started page belongs to the project the - // SCM wizard just created. Without this check a non-SCM getting-started page - // could clobber a stale session that happens to be in storage. - const isWizardSession = - hasScmProjectCreation && session?.createdProjectId === project.id; + // SCM wizard just created. Without this check another project's + // getting-started page could clobber a stale session that happens to be in + // storage. + const isWizardSession = session?.createdProjectId === project.id; const syncProducts = useCallback( (products: ProductSolution[]) => {