platform.tsx 15 KB

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