Files
2024-02-15 17:01:50 +00:00

466 lines
15 KiB
TypeScript

import {useCallback, useContext, useMemo, useState} from 'react';
import {browserHistory} from 'react-router';
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 useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
import slugify from 'sentry/utils/slugify';
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 {normalizeUrl} from 'sentry/utils/withDomainRequired';
import IssueAlertOptions, {
MetricValues,
RuleAction,
} from 'sentry/views/projectInstall/issueAlertOptions';
import {GettingStartedWithProjectContext} from 'sentry/views/projects/gettingStartedWithProjectContext';
export type IssueAlertFragment = Parameters<
React.ComponentProps<typeof IssueAlertOptions>['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<OnboardingSelectedSDK | undefined>(
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<IssueAlertFragment | undefined>(
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 => (
<FrameworkSuggestionModal
{...deps}
organization={organization}
selectedPlatform={selectedPlatform}
onConfigure={selectedFramework => {
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 (
<Access access={canCreateProject ? ['project:read'] : ['project:admin']}>
<div data-test-id="onboarding-info">
<List symbol="colored-numeric">
<Layout.Title withMargins>{t('Create a new project in 3 steps')}</Layout.Title>
<HelpText>
{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: (
<ExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/create-new-project/" />
),
}
)}
</HelpText>
<StyledListItem>{t('Choose your platform')}</StyledListItem>
<PlatformPicker
platform={platform?.key}
defaultCategory={platform?.category}
setPlatform={handlePlatformChange}
organization={organization}
showOther
noAutoFilter
/>
<StyledListItem>{t('Set your alert frequency')}</StyledListItem>
<IssueAlertOptions
{...alertFrequencyDefaultValues}
platformLanguage={platform?.language as SupportedLanguages}
onChange={updatedData => setAlertRuleConfig(updatedData)}
/>
<StyledListItem>{t('Name your project and assign it a team')}</StyledListItem>
<CreateProjectForm
onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
// Prevent the page from reloading
event.preventDefault();
frameworkSelectionEnabled ? handleProjectCreation() : createProject();
}}
>
<div>
<FormLabel>{t('Project name')}</FormLabel>
<ProjectNameInputWrap>
<StyledPlatformIcon platform={platform?.key ?? 'other'} size={20} />
<ProjectNameInput
type="text"
name="name"
placeholder={t('project-name')}
autoComplete="off"
value={projectName}
onChange={e => setProjectName(slugify(e.target.value))}
/>
</ProjectNameInputWrap>
</div>
{!isOrgMemberWithNoAccess && (
<div>
<FormLabel>{t('Team')}</FormLabel>
<TeamSelectInput>
<TeamSelector
allowCreate
name="select-team"
aria-label={t('Select a Team')}
menuPlacement="auto"
clearable={false}
value={team}
placeholder={t('Select a Team')}
onChange={choice => setTeam(choice.value)}
teamFilter={(tm: Team) => tm.access.includes('team:admin')}
/>
</TeamSelectInput>
</div>
)}
<div>
<Tooltip title={submitTooltipText} disabled={formErrorCount === 0}>
<Button
type="submit"
data-test-id="create-project"
priority="primary"
disabled={!canSubmitForm}
>
{t('Create Project')}
</Button>
</Tooltip>
</div>
</CreateProjectForm>
{errors && (
<Alert type="error">
{Object.keys(errors).map(key => (
<div key={key}>
<strong>{startCase(key)}</strong>: {errors[key]}
</div>
))}
</Alert>
)}
</List>
</div>
</Access>
);
}
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;
`;