setupDocs.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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. const 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. project,
  75. newOrg,
  76. }: {
  77. location: Location;
  78. organization: Organization;
  79. project: Project;
  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}/${project.slug}/docs/${loadPlatform}/`],
  112. {
  113. staleTime: Infinity,
  114. enabled: !!project.slug && !!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 loaderOnboarding = !!organization.features?.includes('onboarding-project-loader');
  253. const jsDynamicLoader = !!organization.features?.includes('js-sdk-dynamic-loader');
  254. const selectedPlatforms = clientState?.selectedPlatforms || [];
  255. const platformToProjectIdMap = clientState?.platformToProjectIdMap || {};
  256. // id is really slug here
  257. const projectSlugs = selectedPlatforms
  258. .map(platform => platformToProjectIdMap[platform])
  259. .filter((slug): slug is string => slug !== undefined);
  260. const selectedProjectsSet = new Set(projectSlugs);
  261. // get projects in the order they appear in selectedPlatforms
  262. const projects = projectSlugs
  263. .map(slug => rawProjects.find(project => project.slug === slug))
  264. .filter((project): project is Project => project !== undefined);
  265. // SDK instrumentation
  266. const [hasError, setHasError] = useState(false);
  267. const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
  268. const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
  269. // store what projects have sent first event in state based project.firstEvent
  270. const [hasFirstEventMap, setHasFirstEventMap] = useState<Record<string, boolean>>(
  271. projects.reduce((accum, project: Project) => {
  272. accum[project.id] = !!project.firstEvent;
  273. return accum;
  274. }, {} as Record<string, boolean>)
  275. );
  276. const checkProjectHasFirstEvent = (project: Project) => {
  277. return !!hasFirstEventMap[project.id];
  278. };
  279. const {project_id: rawProjectId} = qs.parse(search);
  280. const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
  281. const firstProjectNoError = projects.findIndex(p => selectedProjectsSet.has(p.slug));
  282. // Select a project based on search params. If non exist, use the first project without first event.
  283. const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
  284. const project =
  285. projects[projectIndex] ?? rawProjects.find(p => p.slug === selectedProjectSlug);
  286. // find the next project that doesn't have a first event
  287. const nextProject = projects.find(
  288. (p, i) => i > projectIndex && !checkProjectHasFirstEvent(p)
  289. );
  290. const integrationSlug = project?.platform && platformToIntegrationMap[project.platform];
  291. const [integrationUseManualSetup, setIntegrationUseManualSetup] = useState(false);
  292. const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
  293. const [showLoaderOnboarding, setShowLoaderOnboarding] = useState(
  294. loaderOnboarding && jsDynamicLoader && currentPlatform === 'javascript'
  295. );
  296. const showIntegrationOnboarding = integrationSlug && !integrationUseManualSetup;
  297. const showReactOnboarding =
  298. currentPlatform === 'javascript-react' && docsWithProductSelection;
  299. const hideLoaderOnboarding = useCallback(() => {
  300. setShowLoaderOnboarding(false);
  301. if (!project?.id) {
  302. return;
  303. }
  304. trackAdvancedAnalyticsEvent('onboarding.js_loader_npm_docs_shown', {
  305. organization,
  306. platform: currentPlatform,
  307. project_id: project?.id,
  308. });
  309. }, [organization, currentPlatform, project?.id]);
  310. const fetchData = useCallback(async () => {
  311. // TODO: add better error handling logic
  312. if (!project?.platform) {
  313. return;
  314. }
  315. // this will be fetched in the SetupDocsReact component
  316. if (showReactOnboarding) {
  317. return;
  318. }
  319. // Show loader setup for base javascript platform
  320. if (showLoaderOnboarding) {
  321. return;
  322. }
  323. if (showIntegrationOnboarding) {
  324. setLoadedPlatform(project.platform);
  325. setPlatformDocs(null);
  326. setHasError(false);
  327. return;
  328. }
  329. try {
  330. const loadedDocs = await loadDocs({
  331. api,
  332. orgSlug: organization.slug,
  333. projectSlug: project.slug,
  334. platform: project.platform as PlatformKey,
  335. });
  336. setPlatformDocs(loadedDocs);
  337. setLoadedPlatform(project.platform);
  338. setHasError(false);
  339. } catch (error) {
  340. setHasError(error);
  341. throw error;
  342. }
  343. }, [
  344. project?.slug,
  345. project?.platform,
  346. api,
  347. organization.slug,
  348. showReactOnboarding,
  349. showIntegrationOnboarding,
  350. showLoaderOnboarding,
  351. ]);
  352. useEffect(() => {
  353. fetchData();
  354. }, [fetchData, location.query.product, project?.platform]);
  355. // log experiment on mount if feature enabled
  356. useEffect(() => {
  357. if (heartbeatFooter) {
  358. newFooterLogExperiment();
  359. }
  360. }, [newFooterLogExperiment, heartbeatFooter]);
  361. if (!project) {
  362. return null;
  363. }
  364. const setNewProject = (newProjectId: string) => {
  365. setLoadedPlatform(null);
  366. setPlatformDocs(null);
  367. setHasError(false);
  368. setIntegrationUseManualSetup(false);
  369. const searchParams = new URLSearchParams({
  370. sub_step: 'project',
  371. project_id: newProjectId,
  372. });
  373. browserHistory.push(`${window.location.pathname}?${searchParams}`);
  374. clientState &&
  375. setClientState({
  376. ...clientState,
  377. state: 'projects_selected',
  378. url: `setup-docs/?${searchParams}`,
  379. });
  380. };
  381. const selectProject = (newProjectId: string) => {
  382. const matchedProject = projects.find(p => p.id === newProjectId);
  383. trackAdvancedAnalyticsEvent('growth.onboarding_clicked_project_in_sidebar', {
  384. organization,
  385. platform: matchedProject?.platform || 'unknown',
  386. });
  387. setNewProject(newProjectId);
  388. };
  389. return (
  390. <Fragment>
  391. <Wrapper>
  392. {!singleSelectPlatform && (
  393. <SidebarWrapper>
  394. <ProjectSidebarSection
  395. projects={projects}
  396. selectedPlatformToProjectIdMap={Object.fromEntries(
  397. selectedPlatforms.map(platform => [
  398. platform,
  399. platformToProjectIdMap[platform],
  400. ])
  401. )}
  402. activeProject={project}
  403. {...{checkProjectHasFirstEvent, selectProject}}
  404. />
  405. </SidebarWrapper>
  406. )}
  407. <MainContent>
  408. {showIntegrationOnboarding ? (
  409. <IntegrationSetup
  410. integrationSlug={integrationSlug}
  411. project={project}
  412. onClickManualSetup={() => {
  413. setIntegrationUseManualSetup(true);
  414. }}
  415. />
  416. ) : showReactOnboarding ? (
  417. <ProjectDocsReact
  418. organization={organization}
  419. project={project}
  420. location={location}
  421. newOrg
  422. />
  423. ) : showLoaderOnboarding ? (
  424. <SetupDocsLoader
  425. organization={organization}
  426. project={project}
  427. location={location}
  428. platform={loadedPlatform}
  429. close={hideLoaderOnboarding}
  430. />
  431. ) : (
  432. <ProjectDocs
  433. platform={loadedPlatform}
  434. project={project}
  435. hasError={hasError}
  436. platformDocs={platformDocs}
  437. onRetry={fetchData}
  438. organization={organization}
  439. />
  440. )}
  441. </MainContent>
  442. </Wrapper>
  443. {heartbeatFooter ? (
  444. newFooterAssignment === 'variant2' ? (
  445. <FooterWithViewSampleErrorButton
  446. projectSlug={project.slug}
  447. projectId={project.id}
  448. route={route}
  449. router={router}
  450. location={location}
  451. newOrg
  452. />
  453. ) : newFooterAssignment === 'variant1' ? (
  454. <Footer
  455. projectSlug={project.slug}
  456. projectId={project.id}
  457. route={route}
  458. router={router}
  459. location={location}
  460. newOrg
  461. />
  462. ) : (
  463. <FirstEventFooter
  464. project={project}
  465. organization={organization}
  466. isLast={!nextProject}
  467. hasFirstEvent={checkProjectHasFirstEvent(project)}
  468. onClickSetupLater={() => {
  469. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
  470. trackAdvancedAnalyticsEvent(
  471. 'growth.onboarding_clicked_setup_platform_later',
  472. {
  473. organization,
  474. platform: currentPlatform,
  475. project_index: projectIndex,
  476. }
  477. );
  478. if (!project.platform || !clientState) {
  479. browserHistory.push(orgIssuesURL);
  480. return;
  481. }
  482. // if we have a next project, switch to that
  483. if (nextProject) {
  484. setNewProject(nextProject.id);
  485. } else {
  486. setClientState({
  487. ...clientState,
  488. state: 'finished',
  489. });
  490. browserHistory.push(orgIssuesURL);
  491. }
  492. }}
  493. handleFirstIssueReceived={() => {
  494. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  495. setHasFirstEventMap(newHasFirstEventMap);
  496. }}
  497. />
  498. )
  499. ) : (
  500. <FirstEventFooter
  501. project={project}
  502. organization={organization}
  503. isLast={!nextProject}
  504. hasFirstEvent={checkProjectHasFirstEvent(project)}
  505. onClickSetupLater={() => {
  506. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
  507. trackAdvancedAnalyticsEvent(
  508. 'growth.onboarding_clicked_setup_platform_later',
  509. {
  510. organization,
  511. platform: currentPlatform,
  512. project_index: projectIndex,
  513. }
  514. );
  515. if (!project.platform || !clientState) {
  516. browserHistory.push(orgIssuesURL);
  517. return;
  518. }
  519. // if we have a next project, switch to that
  520. if (nextProject) {
  521. setNewProject(nextProject.id);
  522. } else {
  523. setClientState({
  524. ...clientState,
  525. state: 'finished',
  526. });
  527. browserHistory.push(orgIssuesURL);
  528. }
  529. }}
  530. handleFirstIssueReceived={() => {
  531. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  532. setHasFirstEventMap(newHasFirstEventMap);
  533. }}
  534. />
  535. )}
  536. </Fragment>
  537. );
  538. }
  539. export default SetupDocs;
  540. const AnimatedContentWrapper = styled(motion.div)`
  541. overflow: hidden;
  542. `;
  543. AnimatedContentWrapper.defaultProps = {
  544. initial: {
  545. height: 0,
  546. },
  547. animate: {
  548. height: 'auto',
  549. },
  550. exit: {
  551. height: 0,
  552. },
  553. };
  554. const DocsWrapper = styled(motion.div)``;
  555. DocsWrapper.defaultProps = {
  556. initial: {opacity: 0, y: 40},
  557. animate: {opacity: 1, y: 0},
  558. exit: {opacity: 0},
  559. };
  560. const Wrapper = styled('div')`
  561. display: flex;
  562. flex-direction: row;
  563. margin: ${space(2)};
  564. justify-content: center;
  565. `;
  566. const MainContent = styled('div')`
  567. max-width: 850px;
  568. min-width: 0;
  569. flex-grow: 1;
  570. `;
  571. // the number icon will be space(2) + 30px to the left of the margin of center column
  572. // so we need to offset the right margin by that much
  573. // also hide the sidebar if the screen is too small
  574. const SidebarWrapper = styled('div')`
  575. margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
  576. @media (max-width: 1150px) {
  577. display: none;
  578. }
  579. flex-basis: 240px;
  580. flex-grow: 0;
  581. flex-shrink: 0;
  582. min-width: 240px;
  583. `;