setupDocs.tsx 19 KB

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