import {useCallback, useContext, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import omit from 'lodash/omit'; import startCase from 'lodash/startCase'; import {PlatformIcon} from 'platformicons'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; import Access from 'sentry/components/acl/access'; import {Alert} from 'sentry/components/alert'; import {Button} from 'sentry/components/button'; import Input from 'sentry/components/input'; import * as Layout from 'sentry/components/layouts/thirds'; import ExternalLink from 'sentry/components/links/externalLink'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import type {Platform} from 'sentry/components/platformPicker'; import PlatformPicker from 'sentry/components/platformPicker'; import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess'; import TeamSelector from 'sentry/components/teamSelector'; import {Tooltip} from 'sentry/components/tooltip'; import {t, tct} from 'sentry/locale'; import ProjectsStore from 'sentry/stores/projectsStore'; import {space} from 'sentry/styles/space'; import type {OnboardingSelectedSDK, Team} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import {browserHistory} from 'sentry/utils/browserHistory'; import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import slugify from 'sentry/utils/slugify'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import useApi from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useTeams} from 'sentry/utils/useTeams'; import IssueAlertOptions, { MetricValues, RuleAction, } from 'sentry/views/projectInstall/issueAlertOptions'; import {GettingStartedWithProjectContext} from 'sentry/views/projects/gettingStartedWithProjectContext'; export type IssueAlertFragment = Parameters< React.ComponentProps['onChange'] >[0]; function CreateProject() { const api = useApi(); const organization = useOrganization(); const location = useLocation(); const gettingStartedWithProjectContext = useContext(GettingStartedWithProjectContext); const {teams} = useTeams(); const autoFill = location.query.referrer === 'getting-started' && location.query.project === gettingStartedWithProjectContext.project?.id; const accessTeams = teams.filter((team: Team) => team.access.includes('team:admin')); useRouteAnalyticsEventNames( 'project_creation_page.viewed', 'Project Create: Creation page viewed' ); const [projectName, setProjectName] = useState( autoFill ? gettingStartedWithProjectContext.project?.name : '' ); const [platform, setPlatform] = useState( autoFill ? gettingStartedWithProjectContext.project?.platform : undefined ); const [team, setTeam] = useState( autoFill ? gettingStartedWithProjectContext.project?.teamSlug ?? accessTeams?.[0]?.slug : accessTeams?.[0]?.slug ); const [errors, setErrors] = useState(false); const [inFlight, setInFlight] = useState(false); const [alertRuleConfig, setAlertRuleConfig] = useState( undefined ); const frameworkSelectionEnabled = !!organization?.features.includes( 'onboarding-sdk-selection' ); const createProject = useCallback( async (selectedFramework?: OnboardingSelectedSDK) => { const {slug} = organization; const { shouldCreateCustomRule, name, conditions, actions, actionMatch, frequency, defaultRules, } = alertRuleConfig || {}; const selectedPlatform = selectedFramework ?? platform; if (!selectedPlatform) { addErrorMessage(t('Please select a platform in Step 1')); return; } setInFlight(true); try { const url = team ? `/teams/${slug}/${team}/projects/` : `/organizations/${slug}/experimental/projects/`; const projectData = await api.requestPromise(url, { method: 'POST', data: { name: projectName, platform: selectedPlatform.key, default_rules: defaultRules ?? true, }, }); let ruleId: string | undefined; if (shouldCreateCustomRule) { const ruleData = await api.requestPromise( `/projects/${organization.slug}/${projectData.slug}/rules/`, { method: 'POST', data: { name, conditions, actions, actionMatch, frequency, }, } ); ruleId = ruleData.id; } trackAnalytics('project_creation_page.created', { organization, issue_alert: defaultRules ? 'Default' : shouldCreateCustomRule ? 'Custom' : 'No Rule', project_id: projectData.id, rule_id: ruleId || '', }); ProjectsStore.onCreateSuccess(projectData, organization.slug); if (team) { addSuccessMessage( tct('Created project [project]', { project: `${projectData.slug}`, }) ); } else { addSuccessMessage( tct('Created [project] under new team [team]', { project: `${projectData.slug}`, team: `#${projectData.team_slug}`, }) ); } browserHistory.push( normalizeUrl( `/organizations/${organization.slug}/projects/${projectData.slug}/getting-started/` ) ); } catch (err) { setInFlight(false); setErrors(err.responseJSON); addErrorMessage( tct('Failed to create project [project]', { project: `${projectName}`, }) ); // Only log this if the error is something other than: // * The user not having access to create a project, or, // * A project with that slug already exists if (err.status !== 403 && err.status !== 409) { Sentry.withScope(scope => { scope.setExtra('err', err); Sentry.captureMessage('Project creation failed'); }); } } }, [api, alertRuleConfig, organization, platform, projectName, team] ); const handleProjectCreation = useCallback(async () => { const selectedPlatform = platform; if (!selectedPlatform) { addErrorMessage(t('Please select a platform in Step 1')); return; } if ( selectedPlatform.type !== 'language' || !Object.values(SupportedLanguages).includes( selectedPlatform.language as SupportedLanguages ) ) { createProject(); return; } const {FrameworkSuggestionModal, modalCss} = await import( 'sentry/components/onboarding/frameworkSuggestionModal' ); openModal( deps => ( { createProject(selectedFramework); }} onSkip={createProject} /> ), { modalCss, onClose: () => { trackAnalytics('project_creation.select_framework_modal_close_button_clicked', { platform: selectedPlatform.key, organization, }); }, } ); }, [platform, createProject, organization]); function handlePlatformChange(selectedPlatform: Platform | null) { if (!selectedPlatform?.id) { setPlatform(undefined); setProjectName(''); return; } const userModifiedName = !!projectName && projectName !== platform?.key; const newName = userModifiedName ? projectName : selectedPlatform.id; setPlatform({ ...omit(selectedPlatform, 'id'), key: selectedPlatform.id, }); setProjectName(newName); } const {shouldCreateCustomRule, conditions} = alertRuleConfig || {}; const {canCreateProject} = useProjectCreationAccess({organization}); const canCreateTeam = organization.access.includes('project:admin'); const isOrgMemberWithNoAccess = accessTeams.length === 0 && !canCreateTeam; const isMissingTeam = !isOrgMemberWithNoAccess && !team; const isMissingProjectName = projectName === ''; const isMissingAlertThreshold = shouldCreateCustomRule && !conditions?.every?.(condition => condition.value); const formErrorCount = [ isMissingTeam, isMissingProjectName, isMissingAlertThreshold, ].filter(value => value).length; const canSubmitForm = !inFlight && canCreateProject && formErrorCount === 0; let submitTooltipText: string = t('Please select a team'); if (formErrorCount > 1) { submitTooltipText = t('Please fill out all the required fields'); } else if (isMissingProjectName) { submitTooltipText = t('Please provide a project name'); } else if (isMissingAlertThreshold) { submitTooltipText = t('Please provide an alert threshold'); } const alertFrequencyDefaultValues = useMemo(() => { if (!autoFill) { return {}; } const alertRules = gettingStartedWithProjectContext.project?.alertRules; if (alertRules?.length === 0) { return { alertSetting: String(RuleAction.CREATE_ALERT_LATER), }; } if ( alertRules?.[0].conditions?.[0].id?.endsWith('EventFrequencyCondition') || alertRules?.[0].conditions?.[0].id?.endsWith('EventUniqueUserFrequencyCondition') ) { return { alertSetting: String(RuleAction.CUSTOMIZED_ALERTS), interval: String(alertRules?.[0].conditions?.[0].interval), threshold: String(alertRules?.[0].conditions?.[0].value), metric: alertRules?.[0].conditions?.[0].id?.endsWith('EventFrequencyCondition') ? MetricValues.ERRORS : MetricValues.USERS, }; } return { alertSetting: String(RuleAction.DEFAULT_ALERT), }; }, [autoFill, gettingStartedWithProjectContext.project?.alertRules]); 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')} setAlertRuleConfig(updatedData)} /> {t('Name your project and assign it a team')} ) => { // Prevent the page from reloading event.preventDefault(); frameworkSelectionEnabled ? handleProjectCreation() : createProject(); }} >
{t('Project name')} setProjectName(slugify(e.target.value))} />
{!isOrgMemberWithNoAccess && (
{t('Team')} setTeam(choice.value)} teamFilter={(tm: Team) => tm.access.includes('team:admin')} />
)}
{errors && ( {Object.keys(errors).map(key => (
{startCase(key)}: {errors[key]}
))}
)}
); } export {CreateProject}; const StyledListItem = styled(ListItem)` margin: ${space(2)} 0 ${space(1)} 0; font-size: ${p => p.theme.fontSizeExtraLarge}; `; const CreateProjectForm = styled('form')` display: grid; grid-template-columns: 300px minmax(250px, max-content) max-content; gap: ${space(2)}; align-items: end; padding: ${space(3)} 0; background: ${p => p.theme.background}; `; const FormLabel = styled('div')` font-size: ${p => p.theme.fontSizeExtraLarge}; margin-bottom: ${space(1)}; `; const ProjectNameInputWrap = styled('div')` position: relative; `; const ProjectNameInput = styled(Input)` padding-left: calc(${p => p.theme.formPadding.md.paddingLeft}px * 1.5 + 20px); `; const StyledPlatformIcon = styled(PlatformIcon)` position: absolute; top: 50%; left: ${p => p.theme.formPadding.md.paddingLeft}px; transform: translateY(-50%); `; const TeamSelectInput = styled('div')` display: grid; gap: ${space(1)}; grid-template-columns: 1fr min-content; align-items: center; `; const HelpText = styled('p')` color: ${p => p.theme.subText}; max-width: 760px; `;