setupDocs.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {motion} from 'framer-motion';
  5. import {Location} from 'history';
  6. import * as qs from 'query-string';
  7. import {loadDocs} from 'sentry/actionCreators/projects';
  8. import {Alert} from 'sentry/components/alert';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import LoadingError from 'sentry/components/loadingError';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import {DocumentationWrapper} from 'sentry/components/onboarding/documentationWrapper';
  13. import {Footer} from 'sentry/components/onboarding/footer';
  14. import {FooterWithViewSampleErrorButton} from 'sentry/components/onboarding/footerWithViewSampleErrorButton';
  15. import {PRODUCT, ProductSelection} from 'sentry/components/onboarding/productSelection';
  16. import {PlatformKey} from 'sentry/data/platformCategories';
  17. import platforms, {ReactDocVariant} from 'sentry/data/platforms';
  18. import {t, tct} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import {Organization, Project} from 'sentry/types';
  21. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  22. import getDynamicText from 'sentry/utils/getDynamicText';
  23. import {platformToIntegrationMap} from 'sentry/utils/integrationUtil';
  24. import {useApiQuery} from 'sentry/utils/queryClient';
  25. import useApi from 'sentry/utils/useApi';
  26. import {useExperiment} from 'sentry/utils/useExperiment';
  27. import useOrganization from 'sentry/utils/useOrganization';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import SetupIntroduction from 'sentry/views/onboarding/components/setupIntroduction';
  30. import {SetupDocsLoader} from 'sentry/views/onboarding/setupDocsLoader';
  31. import FirstEventFooter from './components/firstEventFooter';
  32. import ProjectSidebarSection from './components/projectSidebarSection';
  33. import IntegrationSetup from './integrationSetup';
  34. import {StepProps} from './types';
  35. import {usePersistedOnboardingState} from './utils';
  36. /**
  37. * The documentation will include the following string should it be missing the
  38. * verification example, which currently a lot of docs are.
  39. */
  40. const INCOMPLETE_DOC_FLAG = 'TODO-ADD-VERIFICATION-EXAMPLE';
  41. type PlatformDoc = {html: string; link: string};
  42. type Props = {
  43. search: string;
  44. } & StepProps;
  45. function MissingExampleWarning({
  46. platformDocs,
  47. platform,
  48. }: {
  49. platform: PlatformKey | null;
  50. platformDocs: PlatformDoc | null;
  51. }) {
  52. const missingExample = platformDocs?.html.includes(INCOMPLETE_DOC_FLAG);
  53. if (!missingExample) {
  54. return null;
  55. }
  56. return (
  57. <Alert type="warning" showIcon>
  58. {tct(
  59. `Looks like this getting started example is still undergoing some
  60. work and doesn't include an example for triggering an event quite
  61. yet. If you have trouble sending your first event be sure to consult
  62. the [docsLink:full documentation] for [platform].`,
  63. {
  64. docsLink: <ExternalLink href={platformDocs?.link} />,
  65. platform: platforms.find(p => p.id === platform)?.name,
  66. }
  67. )}
  68. </Alert>
  69. );
  70. }
  71. export function ProjectDocsReact({
  72. organization,
  73. location,
  74. projectSlug,
  75. newOrg,
  76. }: {
  77. location: Location;
  78. organization: Organization;
  79. projectSlug: Project['slug'];
  80. newOrg?: boolean;
  81. }) {
  82. const {
  83. experimentAssignment: productSelectionAssignment,
  84. logExperiment: productSelectionLogExperiment,
  85. } = useExperiment('OnboardingProductSelectionExperiment', {
  86. logExperimentOnMount: false,
  87. });
  88. // This is an experiment we are doing with react.
  89. // In this experiment we let the user choose which Sentry product he would like to have in his `Sentry.Init()`
  90. // and the docs will reflect that.
  91. const loadPlatform = useMemo(() => {
  92. const products = location.query.product ?? [];
  93. return newOrg && productSelectionAssignment === 'baseline'
  94. ? 'javascript-react'
  95. : products.includes(PRODUCT.PERFORMANCE_MONITORING) &&
  96. products.includes(PRODUCT.SESSION_REPLAY)
  97. ? ReactDocVariant.ErrorMonitoringPerformanceAndReplay
  98. : products.includes(PRODUCT.PERFORMANCE_MONITORING)
  99. ? ReactDocVariant.ErrorMonitoringAndPerformance
  100. : products.includes(PRODUCT.SESSION_REPLAY)
  101. ? ReactDocVariant.ErrorMonitoringAndSessionReplay
  102. : ReactDocVariant.ErrorMonitoring;
  103. }, [location.query.product, productSelectionAssignment, newOrg]);
  104. useEffect(() => {
  105. if (!newOrg) {
  106. return;
  107. }
  108. productSelectionLogExperiment();
  109. }, [productSelectionLogExperiment, newOrg]);
  110. const {data, isLoading, isError, refetch} = useApiQuery<PlatformDoc>(
  111. [`/projects/${organization.slug}/${projectSlug}/docs/${loadPlatform}/`],
  112. {
  113. staleTime: Infinity,
  114. enabled: !!projectSlug && !!organization.slug && !!loadPlatform,
  115. }
  116. );
  117. return (
  118. <Fragment>
  119. {newOrg ? (
  120. <Fragment>
  121. <SetupIntroduction
  122. stepHeaderText={t(
  123. 'Configure %s SDK',
  124. platforms.find(p => p.id === 'javascript-react')?.name ?? ''
  125. )}
  126. platform="javascript-react"
  127. />
  128. {productSelectionAssignment === 'variant1' ? (
  129. <ProductSelection
  130. defaultSelectedProducts={[
  131. PRODUCT.PERFORMANCE_MONITORING,
  132. PRODUCT.SESSION_REPLAY,
  133. ]}
  134. />
  135. ) : productSelectionAssignment === 'variant2' ? (
  136. <ProductSelection />
  137. ) : null}
  138. </Fragment>
  139. ) : (
  140. <ProductSelection
  141. defaultSelectedProducts={[
  142. PRODUCT.PERFORMANCE_MONITORING,
  143. PRODUCT.SESSION_REPLAY,
  144. ]}
  145. />
  146. )}
  147. {isLoading ? (
  148. <LoadingIndicator />
  149. ) : isError ? (
  150. <LoadingError
  151. message={t('Failed to load documentation for the React platform.')}
  152. onRetry={refetch}
  153. />
  154. ) : (
  155. getDynamicText({
  156. value: (
  157. <DocsWrapper>
  158. <DocumentationWrapper
  159. dangerouslySetInnerHTML={{__html: data?.html ?? ''}}
  160. />
  161. <MissingExampleWarning
  162. platform="javascript-react"
  163. platformDocs={{
  164. html: data?.html ?? '',
  165. link: data?.link ?? '',
  166. }}
  167. />
  168. </DocsWrapper>
  169. ),
  170. fixed: (
  171. <Alert type="warning">
  172. Platform documentation is not rendered in for tests in CI
  173. </Alert>
  174. ),
  175. })
  176. )}
  177. </Fragment>
  178. );
  179. }
  180. function ProjectDocs(props: {
  181. hasError: boolean;
  182. onRetry: () => void;
  183. organization: Organization;
  184. platform: PlatformKey | null;
  185. platformDocs: PlatformDoc | null;
  186. project: Project;
  187. }) {
  188. const currentPlatform = props.platform ?? props.project?.platform ?? 'other';
  189. return (
  190. <Fragment>
  191. <SetupIntroduction
  192. stepHeaderText={t(
  193. 'Configure %s SDK',
  194. platforms.find(p => p.id === currentPlatform)?.name ?? ''
  195. )}
  196. platform={currentPlatform}
  197. />
  198. {getDynamicText({
  199. value: !props.hasError ? (
  200. props.platformDocs !== null && (
  201. <DocsWrapper key={props.platformDocs.html}>
  202. <DocumentationWrapper
  203. dangerouslySetInnerHTML={{__html: props.platformDocs.html}}
  204. />
  205. <MissingExampleWarning
  206. platform={props.platform}
  207. platformDocs={props.platformDocs}
  208. />
  209. </DocsWrapper>
  210. )
  211. ) : (
  212. <LoadingError
  213. message={t(
  214. 'Failed to load documentation for the %s platform.',
  215. props.project?.platform
  216. )}
  217. onRetry={props.onRetry}
  218. />
  219. ),
  220. fixed: (
  221. <Alert type="warning">
  222. Platform documentation is not rendered in for tests in CI
  223. </Alert>
  224. ),
  225. })}
  226. </Fragment>
  227. );
  228. }
  229. function SetupDocs({search, route, router, location, ...props}: Props) {
  230. const api = useApi();
  231. const organization = useOrganization();
  232. const {projects: rawProjects} = useProjects();
  233. const [clientState, setClientState] = usePersistedOnboardingState();
  234. const [selectedProjectSlug, _setSelectedProjectSlug] = useState(
  235. props.selectedProjectSlug
  236. );
  237. const {
  238. logExperiment: newFooterLogExperiment,
  239. experimentAssignment: newFooterAssignment,
  240. } = useExperiment('OnboardingNewFooterExperiment', {
  241. logExperimentOnMount: false,
  242. });
  243. const singleSelectPlatform = !!organization?.features.includes(
  244. 'onboarding-remove-multiselect-platform'
  245. );
  246. const heartbeatFooter = !!organization?.features.includes(
  247. 'onboarding-heartbeat-footer'
  248. );
  249. const docsWithProductSelection = !!organization.features?.includes(
  250. 'onboarding-docs-with-product-selection'
  251. );
  252. const selectedPlatforms = clientState?.selectedPlatforms || [];
  253. const platformToProjectIdMap = clientState?.platformToProjectIdMap || {};
  254. // id is really slug here
  255. const projectSlugs = selectedPlatforms
  256. .map(platform => platformToProjectIdMap[platform])
  257. .filter((slug): slug is string => slug !== undefined);
  258. const selectedProjectsSet = new Set(projectSlugs);
  259. // get projects in the order they appear in selectedPlatforms
  260. const projects = projectSlugs
  261. .map(slug => rawProjects.find(project => project.slug === slug))
  262. .filter((project): project is Project => project !== undefined);
  263. // SDK instrumentation
  264. const [hasError, setHasError] = useState(false);
  265. const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
  266. const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
  267. // store what projects have sent first event in state based project.firstEvent
  268. const [hasFirstEventMap, setHasFirstEventMap] = useState<Record<string, boolean>>(
  269. projects.reduce((accum, project: Project) => {
  270. accum[project.id] = !!project.firstEvent;
  271. return accum;
  272. }, {} as Record<string, boolean>)
  273. );
  274. const checkProjectHasFirstEvent = (project: Project) => {
  275. return !!hasFirstEventMap[project.id];
  276. };
  277. const {project_id: rawProjectId} = qs.parse(search);
  278. const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
  279. const firstProjectNoError = projects.findIndex(p => selectedProjectsSet.has(p.slug));
  280. // Select a project based on search params. If non exist, use the first project without first event.
  281. const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
  282. const project =
  283. projects[projectIndex] ?? rawProjects.find(p => p.slug === selectedProjectSlug);
  284. // find the next project that doesn't have a first event
  285. const nextProject = projects.find(
  286. (p, i) => i > projectIndex && !checkProjectHasFirstEvent(p)
  287. );
  288. const integrationSlug = project?.platform && platformToIntegrationMap[project.platform];
  289. const [integrationUseManualSetup, setIntegrationUseManualSetup] = useState(false);
  290. const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
  291. const [showLoaderOnboarding, setShowLoaderOnboarding] = useState(
  292. currentPlatform === 'javascript'
  293. );
  294. const showIntegrationOnboarding = integrationSlug && !integrationUseManualSetup;
  295. const showReactOnboarding =
  296. currentPlatform === 'javascript-react' && docsWithProductSelection;
  297. const hideLoaderOnboarding = useCallback(() => {
  298. setShowLoaderOnboarding(false);
  299. if (!project?.id) {
  300. return;
  301. }
  302. trackAdvancedAnalyticsEvent('onboarding.js_loader_npm_docs_shown', {
  303. organization,
  304. platform: currentPlatform,
  305. project_id: project?.id,
  306. });
  307. }, [organization, currentPlatform, project?.id]);
  308. const fetchData = useCallback(async () => {
  309. // TODO: add better error handling logic
  310. if (!project?.platform) {
  311. return;
  312. }
  313. // this will be fetched in the SetupDocsReact component
  314. if (showReactOnboarding) {
  315. return;
  316. }
  317. // Show loader setup for base javascript platform
  318. if (showLoaderOnboarding) {
  319. return;
  320. }
  321. if (showIntegrationOnboarding) {
  322. setLoadedPlatform(project.platform);
  323. setPlatformDocs(null);
  324. setHasError(false);
  325. return;
  326. }
  327. try {
  328. const loadedDocs = await loadDocs({
  329. api,
  330. orgSlug: organization.slug,
  331. projectSlug: project.slug,
  332. platform: project.platform as PlatformKey,
  333. });
  334. setPlatformDocs(loadedDocs);
  335. setLoadedPlatform(project.platform);
  336. setHasError(false);
  337. } catch (error) {
  338. setHasError(error);
  339. throw error;
  340. }
  341. }, [
  342. project?.slug,
  343. project?.platform,
  344. api,
  345. organization.slug,
  346. showReactOnboarding,
  347. showIntegrationOnboarding,
  348. showLoaderOnboarding,
  349. ]);
  350. useEffect(() => {
  351. fetchData();
  352. }, [fetchData, location.query.product, project?.platform]);
  353. // log experiment on mount if feature enabled
  354. useEffect(() => {
  355. if (heartbeatFooter) {
  356. newFooterLogExperiment();
  357. }
  358. }, [newFooterLogExperiment, heartbeatFooter]);
  359. if (!project) {
  360. return null;
  361. }
  362. const setNewProject = (newProjectId: string) => {
  363. setLoadedPlatform(null);
  364. setPlatformDocs(null);
  365. setHasError(false);
  366. setIntegrationUseManualSetup(false);
  367. const searchParams = new URLSearchParams({
  368. sub_step: 'project',
  369. project_id: newProjectId,
  370. });
  371. browserHistory.push(`${window.location.pathname}?${searchParams}`);
  372. clientState &&
  373. setClientState({
  374. ...clientState,
  375. state: 'projects_selected',
  376. url: `setup-docs/?${searchParams}`,
  377. });
  378. };
  379. const selectProject = (newProjectId: string) => {
  380. const matchedProject = projects.find(p => p.id === newProjectId);
  381. trackAdvancedAnalyticsEvent('growth.onboarding_clicked_project_in_sidebar', {
  382. organization,
  383. platform: matchedProject?.platform || 'unknown',
  384. });
  385. setNewProject(newProjectId);
  386. };
  387. return (
  388. <Fragment>
  389. <Wrapper>
  390. {!singleSelectPlatform && (
  391. <SidebarWrapper>
  392. <ProjectSidebarSection
  393. projects={projects}
  394. selectedPlatformToProjectIdMap={Object.fromEntries(
  395. selectedPlatforms.map(platform => [
  396. platform,
  397. platformToProjectIdMap[platform],
  398. ])
  399. )}
  400. activeProject={project}
  401. {...{checkProjectHasFirstEvent, selectProject}}
  402. />
  403. </SidebarWrapper>
  404. )}
  405. <MainContent>
  406. {showIntegrationOnboarding ? (
  407. <IntegrationSetup
  408. integrationSlug={integrationSlug}
  409. project={project}
  410. onClickManualSetup={() => {
  411. setIntegrationUseManualSetup(true);
  412. }}
  413. />
  414. ) : showReactOnboarding ? (
  415. <ProjectDocsReact
  416. organization={organization}
  417. projectSlug={project.slug}
  418. location={location}
  419. newOrg
  420. />
  421. ) : showLoaderOnboarding ? (
  422. <Fragment>
  423. <SetupIntroduction
  424. stepHeaderText={t(
  425. 'Configure %s SDK',
  426. platforms.find(p => p.id === currentPlatform)?.name ?? ''
  427. )}
  428. platform={currentPlatform}
  429. />
  430. <SetupDocsLoader
  431. organization={organization}
  432. project={project}
  433. location={location}
  434. platform={loadedPlatform}
  435. close={hideLoaderOnboarding}
  436. />
  437. </Fragment>
  438. ) : (
  439. <ProjectDocs
  440. platform={loadedPlatform}
  441. project={project}
  442. hasError={hasError}
  443. platformDocs={platformDocs}
  444. onRetry={fetchData}
  445. organization={organization}
  446. />
  447. )}
  448. </MainContent>
  449. </Wrapper>
  450. {heartbeatFooter ? (
  451. newFooterAssignment === 'variant2' ? (
  452. <FooterWithViewSampleErrorButton
  453. projectSlug={project.slug}
  454. projectId={project.id}
  455. route={route}
  456. router={router}
  457. location={location}
  458. newOrg
  459. />
  460. ) : newFooterAssignment === 'variant1' ? (
  461. <Footer
  462. projectSlug={project.slug}
  463. projectId={project.id}
  464. route={route}
  465. router={router}
  466. location={location}
  467. newOrg
  468. />
  469. ) : (
  470. <FirstEventFooter
  471. project={project}
  472. organization={organization}
  473. isLast={!nextProject}
  474. hasFirstEvent={checkProjectHasFirstEvent(project)}
  475. onClickSetupLater={() => {
  476. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
  477. trackAdvancedAnalyticsEvent(
  478. 'growth.onboarding_clicked_setup_platform_later',
  479. {
  480. organization,
  481. platform: currentPlatform,
  482. project_index: projectIndex,
  483. }
  484. );
  485. if (!project.platform || !clientState) {
  486. browserHistory.push(orgIssuesURL);
  487. return;
  488. }
  489. // if we have a next project, switch to that
  490. if (nextProject) {
  491. setNewProject(nextProject.id);
  492. } else {
  493. setClientState({
  494. ...clientState,
  495. state: 'finished',
  496. });
  497. browserHistory.push(orgIssuesURL);
  498. }
  499. }}
  500. handleFirstIssueReceived={() => {
  501. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  502. setHasFirstEventMap(newHasFirstEventMap);
  503. }}
  504. />
  505. )
  506. ) : (
  507. <FirstEventFooter
  508. project={project}
  509. organization={organization}
  510. isLast={!nextProject}
  511. hasFirstEvent={checkProjectHasFirstEvent(project)}
  512. onClickSetupLater={() => {
  513. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
  514. trackAdvancedAnalyticsEvent(
  515. 'growth.onboarding_clicked_setup_platform_later',
  516. {
  517. organization,
  518. platform: currentPlatform,
  519. project_index: projectIndex,
  520. }
  521. );
  522. if (!project.platform || !clientState) {
  523. browserHistory.push(orgIssuesURL);
  524. return;
  525. }
  526. // if we have a next project, switch to that
  527. if (nextProject) {
  528. setNewProject(nextProject.id);
  529. } else {
  530. setClientState({
  531. ...clientState,
  532. state: 'finished',
  533. });
  534. browserHistory.push(orgIssuesURL);
  535. }
  536. }}
  537. handleFirstIssueReceived={() => {
  538. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  539. setHasFirstEventMap(newHasFirstEventMap);
  540. }}
  541. />
  542. )}
  543. </Fragment>
  544. );
  545. }
  546. export default SetupDocs;
  547. const AnimatedContentWrapper = styled(motion.div)`
  548. overflow: hidden;
  549. `;
  550. AnimatedContentWrapper.defaultProps = {
  551. initial: {
  552. height: 0,
  553. },
  554. animate: {
  555. height: 'auto',
  556. },
  557. exit: {
  558. height: 0,
  559. },
  560. };
  561. const DocsWrapper = styled(motion.div)``;
  562. DocsWrapper.defaultProps = {
  563. initial: {opacity: 0, y: 40},
  564. animate: {opacity: 1, y: 0},
  565. exit: {opacity: 0},
  566. };
  567. const Wrapper = styled('div')`
  568. display: flex;
  569. flex-direction: row;
  570. margin: ${space(2)};
  571. justify-content: center;
  572. `;
  573. const MainContent = styled('div')`
  574. max-width: 850px;
  575. min-width: 0;
  576. flex-grow: 1;
  577. `;
  578. // the number icon will be space(2) + 30px to the left of the margin of center column
  579. // so we need to offset the right margin by that much
  580. // also hide the sidebar if the screen is too small
  581. const SidebarWrapper = styled('div')`
  582. margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
  583. @media (max-width: 1150px) {
  584. display: none;
  585. }
  586. flex-basis: 240px;
  587. flex-grow: 0;
  588. flex-shrink: 0;
  589. min-width: 240px;
  590. `;