platform.tsx 16 KB

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