platform.tsx 14 KB

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