platform.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import {Fragment, useCallback, useContext, useEffect, useState} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import omit from 'lodash/omit';
  5. import {loadDocs, removeProject} from 'sentry/actionCreators/projects';
  6. import Feature from 'sentry/components/acl/feature';
  7. import {Alert} from 'sentry/components/alert';
  8. import {Button} from 'sentry/components/button';
  9. import ButtonBar from 'sentry/components/buttonBar';
  10. import Confirm from 'sentry/components/confirm';
  11. import NotFound from 'sentry/components/errors/notFound';
  12. import HookOrDefault from 'sentry/components/hookOrDefault';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import LoadingError from 'sentry/components/loadingError';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import {DocumentationWrapper} from 'sentry/components/onboarding/documentationWrapper';
  17. import {Footer} from 'sentry/components/onboarding/footer';
  18. import {useRecentCreatedProject} from 'sentry/components/onboarding/useRecentCreatedProject';
  19. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  20. import {
  21. performance as performancePlatforms,
  22. Platform,
  23. PlatformKey,
  24. } from 'sentry/data/platformCategories';
  25. import platforms from 'sentry/data/platforms';
  26. import {IconChevron} from 'sentry/icons';
  27. import {t, tct} from 'sentry/locale';
  28. import ConfigStore from 'sentry/stores/configStore';
  29. import {space} from 'sentry/styles/space';
  30. import {OnboardingSelectedSDK, Organization, Project} from 'sentry/types';
  31. import {IssueAlertRule} from 'sentry/types/alerts';
  32. import {trackAnalytics} from 'sentry/utils/analytics';
  33. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  34. import {useApiQuery} from 'sentry/utils/queryClient';
  35. import useApi from 'sentry/utils/useApi';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import useProjects from 'sentry/utils/useProjects';
  38. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  39. import {GettingStartedWithProjectContext} from 'sentry/views/projects/gettingStartedWithProjectContext';
  40. // in this case, the default is rendered inside the hook
  41. const SetUpSdkDoc = HookOrDefault({
  42. hookName: 'component:set-up-sdk-doc',
  43. });
  44. type Props = RouteComponentProps<{projectId: string}, {}>;
  45. export function SetUpGeneralSdkDoc({
  46. organization,
  47. projectSlug,
  48. platform,
  49. }: {
  50. organization: Organization;
  51. platform: Platform;
  52. projectSlug: Project['slug'];
  53. }) {
  54. const api = useApi();
  55. const [loading, setLoading] = useState(true);
  56. const [error, setError] = useState(false);
  57. const [html, setHtml] = useState('');
  58. const fetchDocs = useCallback(async () => {
  59. setLoading(true);
  60. try {
  61. const {html: reponse} = await loadDocs({
  62. api,
  63. orgSlug: organization.slug,
  64. projectSlug,
  65. platform: platform.key as PlatformKey,
  66. });
  67. setHtml(reponse);
  68. window.scrollTo(0, 0);
  69. } catch (err) {
  70. setError(err);
  71. }
  72. setLoading(false);
  73. }, [api, organization.slug, projectSlug, platform.key]);
  74. useEffect(() => {
  75. fetchDocs();
  76. }, [fetchDocs]);
  77. return (
  78. <div>
  79. <Alert type="info" showIcon>
  80. {tct(
  81. `
  82. This is a quick getting started guide. For in-depth instructions
  83. on integrating Sentry with [platform], view
  84. [docLink:our complete documentation].`,
  85. {
  86. platform: platform.name,
  87. docLink: <ExternalLink href={platform.link ?? undefined} />,
  88. }
  89. )}
  90. </Alert>
  91. {loading ? (
  92. <LoadingIndicator />
  93. ) : error ? (
  94. <LoadingError onRetry={fetchDocs} />
  95. ) : (
  96. <Fragment>
  97. <SentryDocumentTitle
  98. title={`${t('Configure')} ${platform.name}`}
  99. projectSlug={projectSlug}
  100. />
  101. <DocumentationWrapper dangerouslySetInnerHTML={{__html: html}} />
  102. </Fragment>
  103. )}
  104. </div>
  105. );
  106. }
  107. export function ProjectInstallPlatform({location, params, route, router}: Props) {
  108. const organization = useOrganization();
  109. const api = useApi();
  110. const gettingStartedWithProjectContext = useContext(GettingStartedWithProjectContext);
  111. const isSelfHosted = ConfigStore.get('isSelfHosted');
  112. const {projects, initiallyLoaded} = useProjects({
  113. slugs: [params.projectId],
  114. orgId: organization.slug,
  115. });
  116. const loadingProjects = !initiallyLoaded;
  117. const project = !loadingProjects
  118. ? projects.find(proj => proj.slug === params.projectId)
  119. : undefined;
  120. const {
  121. data: projectAlertRules,
  122. isLoading: projectAlertRulesIsLoading,
  123. isError: projectAlertRulesIsError,
  124. } = useApiQuery<IssueAlertRule[]>(
  125. [`/projects/${organization.slug}/${project?.slug}/rules/`],
  126. {
  127. enabled: !!project?.slug,
  128. staleTime: 0,
  129. }
  130. );
  131. useEffect(() => {
  132. if (!project || projectAlertRulesIsLoading || projectAlertRulesIsError) {
  133. return;
  134. }
  135. if (gettingStartedWithProjectContext.project?.id === project.id) {
  136. return;
  137. }
  138. const platformKey = Object.keys(platforms).find(
  139. key => platforms[key].id === project.platform
  140. );
  141. if (!platformKey) {
  142. return;
  143. }
  144. gettingStartedWithProjectContext.setProject({
  145. id: project.id,
  146. name: project.name,
  147. // sometimes the team slug here can be undefined
  148. teamSlug: project.team?.slug,
  149. alertRules: projectAlertRules,
  150. platform: {
  151. ...omit(platforms[platformKey], 'id'),
  152. key: platforms[platformKey].id,
  153. } as OnboardingSelectedSDK,
  154. });
  155. }, [
  156. gettingStartedWithProjectContext,
  157. project,
  158. projectAlertRules,
  159. projectAlertRulesIsLoading,
  160. projectAlertRulesIsError,
  161. ]);
  162. const heartbeatFooter = !!organization?.features.includes(
  163. 'onboarding-heartbeat-footer'
  164. );
  165. const projectDeletionOnBackClick = !!organization?.features.includes(
  166. 'onboarding-project-deletion-on-back-click'
  167. );
  168. const recentCreatedProject = useRecentCreatedProject({
  169. orgSlug: organization.slug,
  170. projectSlug: project?.slug,
  171. });
  172. const shallProjectBeDeleted =
  173. projectDeletionOnBackClick &&
  174. recentCreatedProject &&
  175. // if the project has received a first error, we don't delete it
  176. recentCreatedProject.firstError === false &&
  177. // if the project has received a first transaction, we don't delete it
  178. recentCreatedProject.firstTransaction === false &&
  179. // if the project has replays, we don't delete it
  180. recentCreatedProject.hasReplays === false &&
  181. // if the project has sessions, we don't delete it
  182. recentCreatedProject.hasSessions === false &&
  183. // if the project is older than one hour, we don't delete it
  184. recentCreatedProject.olderThanOneHour === false;
  185. const currentPlatform = project?.platform ?? 'other';
  186. const platformIntegration = platforms.find(p => p.id === currentPlatform);
  187. const platform: Platform = {
  188. key: currentPlatform as PlatformKey,
  189. id: platformIntegration?.id,
  190. name: platformIntegration?.name,
  191. link: platformIntegration?.link,
  192. };
  193. const redirectToNeutralDocs = useCallback(() => {
  194. if (!project?.slug) {
  195. return;
  196. }
  197. router.push(
  198. normalizeUrl(
  199. `/organizations/${organization.slug}/projects/${project.slug}/getting-started/`
  200. )
  201. );
  202. }, [organization.slug, project?.slug, router]);
  203. const handleGoBack = useCallback(async () => {
  204. if (!recentCreatedProject) {
  205. return;
  206. }
  207. trackAnalytics('project_creation.back_button_clicked', {
  208. organization,
  209. });
  210. if (shallProjectBeDeleted) {
  211. trackAnalytics('project_creation.data_removal_modal_confirm_button_clicked', {
  212. organization,
  213. platform: recentCreatedProject.slug,
  214. project_id: recentCreatedProject.id,
  215. });
  216. try {
  217. await removeProject({
  218. api,
  219. orgSlug: organization.slug,
  220. projectSlug: recentCreatedProject.slug,
  221. origin: 'getting_started',
  222. });
  223. trackAnalytics('project_creation.data_removed', {
  224. organization,
  225. date_created: recentCreatedProject.dateCreated,
  226. platform: recentCreatedProject.slug,
  227. project_id: recentCreatedProject.id,
  228. });
  229. } catch (error) {
  230. handleXhrErrorResponse(t('Unable to delete project in project creation'), error);
  231. // we don't give the user any feedback regarding this error as this shall be silent
  232. }
  233. }
  234. router.replace(
  235. normalizeUrl(
  236. `/organizations/${organization.slug}/projects/new/?referrer=getting-started&project=${recentCreatedProject.id}`
  237. )
  238. );
  239. }, [api, recentCreatedProject, organization, shallProjectBeDeleted, router]);
  240. useEffect(() => {
  241. // redirect if platform is not known.
  242. if (!platform.key || platform.key === 'other') {
  243. redirectToNeutralDocs();
  244. }
  245. }, [platform.key, redirectToNeutralDocs]);
  246. if (!project) {
  247. return null;
  248. }
  249. if (!platform.id) {
  250. return <NotFound />;
  251. }
  252. const issueStreamLink = `/organizations/${organization.slug}/issues/`;
  253. const performanceOverviewLink = `/organizations/${organization.slug}/performance/`;
  254. const showPerformancePrompt = performancePlatforms.includes(platform.id as PlatformKey);
  255. const isGettingStarted = window.location.href.indexOf('getting-started') > 0;
  256. return (
  257. <Fragment>
  258. <StyledPageHeader>
  259. <h2>{t('Configure %(platform)s SDK', {platform: platform.name})}</h2>
  260. <ButtonBar gap={1}>
  261. <Confirm
  262. bypass={!shallProjectBeDeleted}
  263. message={t(
  264. "Hey, just a heads up - we haven't received any data for this SDK yet and by going back all changes will be discarded. Are you sure you want to head back?"
  265. )}
  266. priority="danger"
  267. confirmText={t("Yes I'm sure")}
  268. onConfirm={handleGoBack}
  269. onClose={() => {
  270. if (!recentCreatedProject) {
  271. return;
  272. }
  273. trackAnalytics('project_creation.data_removal_modal_dismissed', {
  274. organization,
  275. platform: recentCreatedProject.slug,
  276. project_id: recentCreatedProject.id,
  277. });
  278. }}
  279. onRender={() => {
  280. if (!recentCreatedProject) {
  281. return;
  282. }
  283. trackAnalytics('project_creation.data_removal_modal_rendered', {
  284. organization,
  285. platform: recentCreatedProject.slug,
  286. project_id: recentCreatedProject.id,
  287. });
  288. }}
  289. >
  290. <Button icon={<IconChevron direction="left" size="sm" />} size="sm">
  291. {t('Back to Platform Selection')}
  292. </Button>
  293. </Confirm>
  294. <Button size="sm" href={platform.link ?? undefined} external>
  295. {t('Full Documentation')}
  296. </Button>
  297. </ButtonBar>
  298. </StyledPageHeader>
  299. <div>
  300. {isSelfHosted ? (
  301. <SetUpGeneralSdkDoc
  302. organization={organization}
  303. projectSlug={project.slug}
  304. platform={platform}
  305. />
  306. ) : (
  307. <SetUpSdkDoc
  308. organization={organization}
  309. project={project}
  310. location={location}
  311. platform={platform}
  312. />
  313. )}
  314. {isGettingStarted && showPerformancePrompt && (
  315. <Feature
  316. features={['performance-view']}
  317. hookName="feature-disabled:performance-new-project"
  318. >
  319. {({hasFeature}) => {
  320. if (hasFeature) {
  321. return null;
  322. }
  323. return (
  324. <StyledAlert type="info" showIcon>
  325. {t(
  326. `Your selected platform supports performance, but your organization does not have performance enabled.`
  327. )}
  328. </StyledAlert>
  329. );
  330. }}
  331. </Feature>
  332. )}
  333. {isGettingStarted && heartbeatFooter ? (
  334. <Footer
  335. projectSlug={params.projectId}
  336. projectId={project?.id}
  337. route={route}
  338. router={router}
  339. location={location}
  340. />
  341. ) : (
  342. <StyledButtonBar gap={1}>
  343. <Button
  344. priority="primary"
  345. busy={loadingProjects}
  346. to={{
  347. pathname: issueStreamLink,
  348. query: project?.id,
  349. hash: '#welcome',
  350. }}
  351. >
  352. {t('Take me to Issues')}
  353. </Button>
  354. <Button
  355. busy={loadingProjects}
  356. to={{
  357. pathname: performanceOverviewLink,
  358. query: project?.id,
  359. }}
  360. >
  361. {t('Take me to Performance')}
  362. </Button>
  363. </StyledButtonBar>
  364. )}
  365. </div>
  366. </Fragment>
  367. );
  368. }
  369. const StyledButtonBar = styled(ButtonBar)`
  370. margin-top: ${space(3)};
  371. width: max-content;
  372. @media (max-width: ${p => p.theme.breakpoints.small}) {
  373. width: auto;
  374. grid-row-gap: ${space(1)};
  375. grid-auto-flow: row;
  376. }
  377. `;
  378. const StyledPageHeader = styled('div')`
  379. display: flex;
  380. justify-content: space-between;
  381. margin-bottom: ${space(3)};
  382. h2 {
  383. margin: 0;
  384. }
  385. @media (max-width: ${p => p.theme.breakpoints.small}) {
  386. flex-direction: column;
  387. align-items: flex-start;
  388. h2 {
  389. margin-bottom: ${space(2)};
  390. }
  391. }
  392. `;
  393. const StyledAlert = styled(Alert)`
  394. margin-top: ${space(2)};
  395. `;