createProject.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. import {Fragment, useCallback, useState} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import {PlatformIcon} from 'platformicons';
  6. import {openCreateTeamModal} from 'sentry/actionCreators/modal';
  7. import {Alert} from 'sentry/components/alert';
  8. import {Button} from 'sentry/components/button';
  9. import Input from 'sentry/components/input';
  10. import * as Layout from 'sentry/components/layouts/thirds';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import PlatformPicker from 'sentry/components/platformPicker';
  13. import TeamSelector from 'sentry/components/teamSelector';
  14. import categoryList from 'sentry/data/platformCategories';
  15. import {IconAdd} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import ProjectsStore from 'sentry/stores/projectsStore';
  18. import {space} from 'sentry/styles/space';
  19. import {Team} from 'sentry/types';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import getPlatformName from 'sentry/utils/getPlatformName';
  22. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  23. import slugify from 'sentry/utils/slugify';
  24. import useApi from 'sentry/utils/useApi';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {useTeams} from 'sentry/utils/useTeams';
  28. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  29. import IssueAlertOptions from 'sentry/views/projectInstall/issueAlertOptions';
  30. const getCategoryName = (category?: string) =>
  31. categoryList.find(({id}) => id === category)?.id;
  32. type PlatformName = React.ComponentProps<typeof PlatformIcon>['platform'];
  33. type IssueAlertFragment = Parameters<
  34. React.ComponentProps<typeof IssueAlertOptions>['onChange']
  35. >[0];
  36. function CreateProject() {
  37. const api = useApi();
  38. const organization = useOrganization();
  39. const location = useLocation();
  40. const {query} = location;
  41. const accessTeams = useTeams().teams.filter((team: Team) => team.hasAccess);
  42. useRouteAnalyticsEventNames(
  43. 'project_creation_page.viewed',
  44. 'Project Create: Creation page viewed'
  45. );
  46. const platformQuery = Array.isArray(query.platform)
  47. ? query.platform[0]
  48. : query.platform ?? null;
  49. const platformName = getPlatformName(platformQuery);
  50. const defaultCategory = getCategoryName(
  51. Array.isArray(query.category) ? query.category[0] : query.category ?? undefined
  52. );
  53. const [projectName, setProjectName] = useState(platformName ?? '');
  54. const [platform, setPlatform] = useState(platformName ? platformQuery : '');
  55. const [team, setTeam] = useState(query.team || accessTeams?.[0]?.slug);
  56. const [error, setError] = useState(false);
  57. const [inFlight, setInFlight] = useState(false);
  58. const [alertRuleConfig, setAlertRuleConfig] = useState<IssueAlertFragment | undefined>(
  59. undefined
  60. );
  61. const createProject = useCallback(
  62. async (e: React.FormEvent) => {
  63. e.preventDefault();
  64. const {slug} = organization;
  65. const {
  66. shouldCreateCustomRule,
  67. name,
  68. conditions,
  69. actions,
  70. actionMatch,
  71. frequency,
  72. defaultRules,
  73. } = alertRuleConfig || {};
  74. setInFlight(true);
  75. try {
  76. const projectData = await api.requestPromise(`/teams/${slug}/${team}/projects/`, {
  77. method: 'POST',
  78. data: {
  79. name: projectName,
  80. platform,
  81. default_rules: defaultRules ?? true,
  82. },
  83. });
  84. let ruleId: string | undefined;
  85. if (shouldCreateCustomRule) {
  86. const ruleData = await api.requestPromise(
  87. `/projects/${organization.slug}/${projectData.slug}/rules/`,
  88. {
  89. method: 'POST',
  90. data: {
  91. name,
  92. conditions,
  93. actions,
  94. actionMatch,
  95. frequency,
  96. },
  97. }
  98. );
  99. ruleId = ruleData.id;
  100. }
  101. trackAnalytics('project_creation_page.created', {
  102. organization,
  103. issue_alert: defaultRules
  104. ? 'Default'
  105. : shouldCreateCustomRule
  106. ? 'Custom'
  107. : 'No Rule',
  108. project_id: projectData.id,
  109. rule_id: ruleId || '',
  110. });
  111. ProjectsStore.onCreateSuccess(projectData, organization.slug);
  112. const platformKey = platform || 'other';
  113. browserHistory.push(
  114. normalizeUrl(
  115. `/${organization.slug}/${projectData.slug}/getting-started/${platformKey}/`
  116. )
  117. );
  118. } catch (err) {
  119. setInFlight(false);
  120. setError(err.responseJSON.detail);
  121. // Only log this if the error is something other than:
  122. // * The user not having access to create a project, or,
  123. // * A project with that slug already exists
  124. if (err.status !== 403 && err.status !== 409) {
  125. Sentry.withScope(scope => {
  126. scope.setExtra('err', err);
  127. Sentry.captureMessage('Project creation failed');
  128. });
  129. }
  130. }
  131. },
  132. [api, alertRuleConfig, organization, platform, projectName, team]
  133. );
  134. function handlePlatformChange(platformKey: PlatformName | null) {
  135. if (!platformKey) {
  136. setPlatform(null);
  137. setProjectName('');
  138. return;
  139. }
  140. const userModifiedName = projectName && projectName !== platform;
  141. const newName = userModifiedName ? projectName : platformKey;
  142. setPlatform(platformKey);
  143. setProjectName(newName);
  144. }
  145. const {shouldCreateCustomRule, conditions} = alertRuleConfig || {};
  146. const canSubmitForm =
  147. !inFlight &&
  148. team &&
  149. projectName !== '' &&
  150. (!shouldCreateCustomRule || conditions?.every?.(condition => condition.value));
  151. const createProjectForm = (
  152. <Fragment>
  153. <Layout.Title withMargins>
  154. {t('3. Name your project and assign it a team')}
  155. </Layout.Title>
  156. <CreateProjectForm onSubmit={createProject}>
  157. <div>
  158. <FormLabel>{t('Project name')}</FormLabel>
  159. <ProjectNameInputWrap>
  160. <StyledPlatformIcon platform={platform ?? ''} size={20} />
  161. <ProjectNameInput
  162. type="text"
  163. name="name"
  164. placeholder={t('project-name')}
  165. autoComplete="off"
  166. value={projectName}
  167. onChange={e => setProjectName(slugify(e.target.value))}
  168. />
  169. </ProjectNameInputWrap>
  170. </div>
  171. <div>
  172. <FormLabel>{t('Team')}</FormLabel>
  173. <TeamSelectInput>
  174. <TeamSelector
  175. name="select-team"
  176. menuPlacement="auto"
  177. clearable={false}
  178. value={team}
  179. placeholder={t('Select a Team')}
  180. onChange={choice => setTeam(choice.value)}
  181. teamFilter={(filterTeam: Team) => filterTeam.hasAccess}
  182. />
  183. <Button
  184. borderless
  185. data-test-id="create-team"
  186. icon={<IconAdd isCircled />}
  187. onClick={() =>
  188. openCreateTeamModal({
  189. organization,
  190. onClose: ({slug}) => setTeam(slug),
  191. })
  192. }
  193. title={t('Create a team')}
  194. aria-label={t('Create a team')}
  195. />
  196. </TeamSelectInput>
  197. </div>
  198. <div>
  199. <Button
  200. type="submit"
  201. data-test-id="create-project"
  202. priority="primary"
  203. disabled={!canSubmitForm}
  204. >
  205. {t('Create Project')}
  206. </Button>
  207. </div>
  208. </CreateProjectForm>
  209. </Fragment>
  210. );
  211. return (
  212. <Fragment>
  213. {error && <Alert type="error">{error}</Alert>}
  214. <div data-test-id="onboarding-info">
  215. <Layout.Title withMargins>{t('Create a new project in 3 steps')}</Layout.Title>
  216. <HelpText>
  217. {tct(
  218. '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].',
  219. {
  220. link: (
  221. <ExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/create-new-project/" />
  222. ),
  223. }
  224. )}
  225. </HelpText>
  226. <Layout.Title withMargins>{t('1. Choose your platform')}</Layout.Title>
  227. <PlatformPicker
  228. platform={platform}
  229. defaultCategory={defaultCategory}
  230. setPlatform={selectedPlatform =>
  231. handlePlatformChange(selectedPlatform?.id ?? null)
  232. }
  233. organization={organization}
  234. showOther
  235. />
  236. <IssueAlertOptions onChange={updatedData => setAlertRuleConfig(updatedData)} />
  237. {createProjectForm}
  238. </div>
  239. </Fragment>
  240. );
  241. }
  242. export {CreateProject};
  243. const CreateProjectForm = styled('form')`
  244. display: grid;
  245. grid-template-columns: 300px minmax(250px, max-content) max-content;
  246. gap: ${space(2)};
  247. align-items: end;
  248. padding: ${space(3)} 0;
  249. box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
  250. background: ${p => p.theme.background};
  251. `;
  252. const FormLabel = styled('div')`
  253. font-size: ${p => p.theme.fontSizeExtraLarge};
  254. margin-bottom: ${space(1)};
  255. `;
  256. const ProjectNameInputWrap = styled('div')`
  257. position: relative;
  258. `;
  259. const ProjectNameInput = styled(Input)`
  260. padding-left: calc(${p => p.theme.formPadding.md.paddingLeft}px * 1.5 + 20px);
  261. `;
  262. const StyledPlatformIcon = styled(PlatformIcon)`
  263. position: absolute;
  264. top: 50%;
  265. left: ${p => p.theme.formPadding.md.paddingLeft}px;
  266. transform: translateY(-50%);
  267. `;
  268. const TeamSelectInput = styled('div')`
  269. display: grid;
  270. gap: ${space(1)};
  271. grid-template-columns: 1fr min-content;
  272. align-items: center;
  273. `;
  274. const HelpText = styled('p')`
  275. color: ${p => p.theme.subText};
  276. max-width: 760px;
  277. `;