platform.tsx 14 KB

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