setupDocs.tsx 17 KB

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