platform.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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 {Footer} from 'sentry/components/onboarding/footer';
  18. import {
  19. migratedDocs,
  20. SdkDocumentation,
  21. } from 'sentry/components/onboarding/gettingStartedDoc/sdkDocumentation';
  22. import {ProductSolution} from 'sentry/components/onboarding/productSelection';
  23. import {useRecentCreatedProject} from 'sentry/components/onboarding/useRecentCreatedProject';
  24. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  25. import {
  26. performance as performancePlatforms,
  27. Platform,
  28. PlatformKey,
  29. } from 'sentry/data/platformCategories';
  30. import platforms from 'sentry/data/platforms';
  31. import {IconChevron} from 'sentry/icons';
  32. import {t, tct} from 'sentry/locale';
  33. import ConfigStore from 'sentry/stores/configStore';
  34. import {space} from 'sentry/styles/space';
  35. import {OnboardingSelectedSDK, Organization, Project} from 'sentry/types';
  36. import {IssueAlertRule} from 'sentry/types/alerts';
  37. import {trackAnalytics} from 'sentry/utils/analytics';
  38. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  39. import {useApiQuery} from 'sentry/utils/queryClient';
  40. import useApi from 'sentry/utils/useApi';
  41. import useOrganization from 'sentry/utils/useOrganization';
  42. import useProjects from 'sentry/utils/useProjects';
  43. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  44. import {SetupDocsLoader} from 'sentry/views/onboarding/setupDocsLoader';
  45. import {GettingStartedWithProjectContext} from 'sentry/views/projects/gettingStartedWithProjectContext';
  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, route, 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 heartbeatFooter = !!organization?.features.includes(
  180. 'onboarding-heartbeat-footer'
  181. );
  182. const projectDeletionOnBackClick = !!organization?.features.includes(
  183. 'onboarding-project-deletion-on-back-click'
  184. );
  185. // This is a feature flag that is currently only enabled for a subset of internal users until the feature is fully implemented,
  186. // but the purpose of the feature is to make the product selection feature in documents available to all users
  187. // and guide them to upgrade to a plan if one of the products is not available on their current plan.
  188. const gettingStartedDocWithProductSelection = !!organization?.features.includes(
  189. 'getting-started-doc-with-product-selection'
  190. );
  191. const recentCreatedProject = useRecentCreatedProject({
  192. orgSlug: organization.slug,
  193. projectSlug: project?.slug,
  194. });
  195. const shallProjectBeDeleted =
  196. projectDeletionOnBackClick &&
  197. recentCreatedProject &&
  198. // if the project has received a first error, we don't delete it
  199. recentCreatedProject.firstError === false &&
  200. // if the project has received a first transaction, we don't delete it
  201. recentCreatedProject.firstTransaction === false &&
  202. // if the project has replays, we don't delete it
  203. recentCreatedProject.hasReplays === false &&
  204. // if the project has sessions, we don't delete it
  205. recentCreatedProject.hasSessions === false &&
  206. // if the project is older than one hour, we don't delete it
  207. recentCreatedProject.olderThanOneHour === false;
  208. const platformIntegration = platforms.find(p => p.id === currentPlatformKey);
  209. const platform: Platform = {
  210. key: currentPlatformKey as PlatformKey,
  211. id: platformIntegration?.id,
  212. name: platformIntegration?.name,
  213. link: platformIntegration?.link,
  214. };
  215. const redirectToNeutralDocs = useCallback(() => {
  216. if (!project?.slug) {
  217. return;
  218. }
  219. router.push(
  220. normalizeUrl(
  221. `/organizations/${organization.slug}/projects/${project.slug}/getting-started/`
  222. )
  223. );
  224. }, [organization.slug, project?.slug, router]);
  225. const handleGoBack = useCallback(async () => {
  226. if (!recentCreatedProject) {
  227. return;
  228. }
  229. trackAnalytics('project_creation.back_button_clicked', {
  230. organization,
  231. });
  232. if (shallProjectBeDeleted) {
  233. trackAnalytics('project_creation.data_removal_modal_confirm_button_clicked', {
  234. organization,
  235. platform: recentCreatedProject.slug,
  236. project_id: recentCreatedProject.id,
  237. });
  238. try {
  239. await removeProject({
  240. api,
  241. orgSlug: organization.slug,
  242. projectSlug: recentCreatedProject.slug,
  243. origin: 'getting_started',
  244. });
  245. trackAnalytics('project_creation.data_removed', {
  246. organization,
  247. date_created: recentCreatedProject.dateCreated,
  248. platform: recentCreatedProject.slug,
  249. project_id: recentCreatedProject.id,
  250. });
  251. } catch (error) {
  252. handleXhrErrorResponse('Unable to delete project in project creation', error);
  253. // we don't give the user any feedback regarding this error as this shall be silent
  254. }
  255. }
  256. router.replace(
  257. normalizeUrl(
  258. `/organizations/${organization.slug}/projects/new/?referrer=getting-started&project=${recentCreatedProject.id}`
  259. )
  260. );
  261. }, [api, recentCreatedProject, organization, shallProjectBeDeleted, router]);
  262. const hideLoaderOnboarding = useCallback(() => {
  263. setShowLoaderOnboarding(false);
  264. if (!project?.id || !currentPlatform) {
  265. return;
  266. }
  267. trackAnalytics('onboarding.js_loader_npm_docs_shown', {
  268. organization,
  269. platform: currentPlatform.id,
  270. project_id: project?.id,
  271. });
  272. }, [organization, currentPlatform, project?.id]);
  273. useEffect(() => {
  274. // redirect if platform is not known.
  275. if (!platform.key || platform.key === 'other') {
  276. redirectToNeutralDocs();
  277. }
  278. }, [platform.key, redirectToNeutralDocs]);
  279. if (!project) {
  280. return null;
  281. }
  282. if (!platform.id) {
  283. return <NotFound />;
  284. }
  285. const issueStreamLink = `/organizations/${organization.slug}/issues/`;
  286. const performanceOverviewLink = `/organizations/${organization.slug}/performance/`;
  287. const showPerformancePrompt = performancePlatforms.includes(platform.id as PlatformKey);
  288. const isGettingStarted = window.location.href.indexOf('getting-started') > 0;
  289. const showDocsWithProductSelection =
  290. gettingStartedDocWithProductSelection &&
  291. (platform.key === 'javascript' || !!platform.key.match('^javascript-([A-Za-z]+)$'));
  292. return (
  293. <Fragment>
  294. {!isSelfHosted && showDocsWithProductSelection && (
  295. <ProductUnavailableCTAHook organization={organization} />
  296. )}
  297. <StyledPageHeader>
  298. <h2>{t('Configure %(platform)s SDK', {platform: platform.name})}</h2>
  299. <ButtonBar gap={1}>
  300. <Confirm
  301. bypass={!shallProjectBeDeleted}
  302. message={t(
  303. "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?"
  304. )}
  305. priority="danger"
  306. confirmText={t("Yes I'm sure")}
  307. onConfirm={handleGoBack}
  308. onClose={() => {
  309. if (!recentCreatedProject) {
  310. return;
  311. }
  312. trackAnalytics('project_creation.data_removal_modal_dismissed', {
  313. organization,
  314. platform: recentCreatedProject.slug,
  315. project_id: recentCreatedProject.id,
  316. });
  317. }}
  318. onRender={() => {
  319. if (!recentCreatedProject) {
  320. return;
  321. }
  322. trackAnalytics('project_creation.data_removal_modal_rendered', {
  323. organization,
  324. platform: recentCreatedProject.slug,
  325. project_id: recentCreatedProject.id,
  326. });
  327. }}
  328. >
  329. <Button icon={<IconChevron direction="left" size="sm" />} size="sm">
  330. {t('Back to Platform Selection')}
  331. </Button>
  332. </Confirm>
  333. <Button size="sm" href={platform.link ?? undefined} external>
  334. {t('Full Documentation')}
  335. </Button>
  336. </ButtonBar>
  337. </StyledPageHeader>
  338. {currentPlatform && showLoaderOnboarding ? (
  339. <SetupDocsLoader
  340. organization={organization}
  341. project={project}
  342. location={location}
  343. platform={currentPlatform.id}
  344. close={hideLoaderOnboarding}
  345. />
  346. ) : currentPlatform && migratedDocs.includes(currentPlatformKey) ? (
  347. <SdkDocumentation
  348. platform={currentPlatform}
  349. orgSlug={organization.slug}
  350. projectSlug={project.slug}
  351. activeProductSelection={products}
  352. />
  353. ) : (
  354. <SetUpGeneralSdkDoc
  355. organization={organization}
  356. projectSlug={project.slug}
  357. platform={platform}
  358. />
  359. )}
  360. <div>
  361. {isGettingStarted && showPerformancePrompt && (
  362. <Feature
  363. features={['performance-view']}
  364. hookName="feature-disabled:performance-new-project"
  365. >
  366. {({hasFeature}) => {
  367. if (hasFeature) {
  368. return null;
  369. }
  370. return (
  371. <StyledAlert type="info" showIcon>
  372. {t(
  373. `Your selected platform supports performance, but your organization does not have performance enabled.`
  374. )}
  375. </StyledAlert>
  376. );
  377. }}
  378. </Feature>
  379. )}
  380. {isGettingStarted && heartbeatFooter ? (
  381. <Footer
  382. projectSlug={params.projectId}
  383. projectId={project?.id}
  384. route={route}
  385. router={router}
  386. location={location}
  387. />
  388. ) : (
  389. <StyledButtonBar gap={1}>
  390. <Button
  391. priority="primary"
  392. busy={loadingProjects}
  393. to={{
  394. pathname: issueStreamLink,
  395. query: project?.id,
  396. hash: '#welcome',
  397. }}
  398. >
  399. {t('Take me to Issues')}
  400. </Button>
  401. <Button
  402. busy={loadingProjects}
  403. to={{
  404. pathname: performanceOverviewLink,
  405. query: project?.id,
  406. }}
  407. >
  408. {t('Take me to Performance')}
  409. </Button>
  410. </StyledButtonBar>
  411. )}
  412. </div>
  413. </Fragment>
  414. );
  415. }
  416. const StyledButtonBar = styled(ButtonBar)`
  417. margin-top: ${space(3)};
  418. width: max-content;
  419. @media (max-width: ${p => p.theme.breakpoints.small}) {
  420. width: auto;
  421. grid-row-gap: ${space(1)};
  422. grid-auto-flow: row;
  423. }
  424. `;
  425. const StyledPageHeader = styled('div')`
  426. display: flex;
  427. justify-content: space-between;
  428. margin-bottom: ${space(3)};
  429. h2 {
  430. margin: 0;
  431. }
  432. @media (max-width: ${p => p.theme.breakpoints.small}) {
  433. flex-direction: column;
  434. align-items: flex-start;
  435. h2 {
  436. margin-bottom: ${space(2)};
  437. }
  438. }
  439. `;
  440. const StyledAlert = styled(Alert)`
  441. margin-top: ${space(2)};
  442. `;