setupDocs.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import 'prism-sentry/index.css';
  2. import {Fragment, useCallback, useEffect, useState} from 'react';
  3. import {browserHistory} from 'react-router';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import {AnimatePresence, motion} from 'framer-motion';
  7. import * as qs from 'query-string';
  8. import {loadDocs} from 'sentry/actionCreators/projects';
  9. import Alert, {alertStyles} from 'sentry/components/alert';
  10. import Button from 'sentry/components/button';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import LoadingError from 'sentry/components/loadingError';
  13. import {PlatformKey} from 'sentry/data/platformCategories';
  14. import platforms from 'sentry/data/platforms';
  15. import {IconChevron} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import space from 'sentry/styles/space';
  18. import {Organization, Project} from 'sentry/types';
  19. import {logExperiment} from 'sentry/utils/analytics';
  20. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  21. import getDynamicText from 'sentry/utils/getDynamicText';
  22. import {platformToIntegrationMap} from 'sentry/utils/integrationUtil';
  23. import {Theme} from 'sentry/utils/theme';
  24. import useApi from 'sentry/utils/useApi';
  25. import withProjects from 'sentry/utils/withProjects';
  26. import FirstEventFooter from './components/firstEventFooter';
  27. import FullIntroduction from './components/fullIntroduction';
  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; wizardSetup: string};
  38. type Props = {
  39. projects: Project[];
  40. search: string;
  41. loadingProjects?: boolean;
  42. } & StepProps;
  43. function ProjecDocs(props: {
  44. hasError: boolean;
  45. onRetry: () => void;
  46. organization: Organization;
  47. platform: PlatformKey | null;
  48. platformDocs: PlatformDoc | null;
  49. project: Project;
  50. }) {
  51. const testOnlyAlert = (
  52. <Alert type="warning">
  53. Platform documentation is not rendered in for tests in CI
  54. </Alert>
  55. );
  56. const missingExampleWarning = () => {
  57. const missingExample =
  58. props.platformDocs && props.platformDocs.html.includes(INCOMPLETE_DOC_FLAG);
  59. if (!missingExample) {
  60. return null;
  61. }
  62. return (
  63. <Alert type="warning" showIcon>
  64. {tct(
  65. `Looks like this getting started example is still undergoing some
  66. work and doesn't include an example for triggering an event quite
  67. yet. If you have trouble sending your first event be sure to consult
  68. the [docsLink:full documentation] for [platform].`,
  69. {
  70. docsLink: <ExternalLink href={props.platformDocs?.link} />,
  71. platform: platforms.find(p => p.id === props.platform)?.name,
  72. }
  73. )}
  74. </Alert>
  75. );
  76. };
  77. useEffect(() => {
  78. props.platformDocs?.wizardSetup &&
  79. logExperiment({
  80. key: 'OnboardingHighlightWizardExperiment',
  81. organization: props.organization,
  82. });
  83. }, [props.organization, props.platformDocs?.wizardSetup]);
  84. const showWizardSetup =
  85. props.organization.experiments.OnboardingHighlightWizardExperiment;
  86. const [wizardSetupDetailsCollapsed, setWizardSetupDetailsCollapsed] = useState(true);
  87. const [interacted, setInteracted] = useState(false);
  88. const docs =
  89. props.platformDocs !== null &&
  90. (showWizardSetup && props.platformDocs.wizardSetup ? (
  91. <DocsWrapper key={props.platformDocs.html}>
  92. <Content
  93. dangerouslySetInnerHTML={{__html: props.platformDocs.wizardSetup}}
  94. onMouseDown={() => {
  95. !interacted &&
  96. trackAdvancedAnalyticsEvent('growth.onboarding_wizard_interacted', {
  97. organization: props.organization,
  98. project_id: props.project.id,
  99. platform: props.platform || 'unknown',
  100. wizard_instructions: true,
  101. });
  102. setInteracted(true);
  103. }}
  104. />
  105. <Button
  106. priority="link"
  107. onClick={() => {
  108. trackAdvancedAnalyticsEvent('growth.onboarding_wizard_clicked_more_details', {
  109. organization: props.organization,
  110. project_id: props.project.id,
  111. platform: props.platform || 'unknown',
  112. });
  113. setWizardSetupDetailsCollapsed(!wizardSetupDetailsCollapsed);
  114. }}
  115. >
  116. <IconChevron
  117. direction={wizardSetupDetailsCollapsed ? 'down' : 'up'}
  118. style={{marginRight: space(1)}}
  119. />
  120. {wizardSetupDetailsCollapsed ? t('More Details') : t('Less Details')}
  121. </Button>
  122. <AnimatePresence>
  123. {!wizardSetupDetailsCollapsed && (
  124. <AnimatedContentWrapper>
  125. <Content dangerouslySetInnerHTML={{__html: props.platformDocs.html}} />
  126. {missingExampleWarning()}
  127. </AnimatedContentWrapper>
  128. )}
  129. </AnimatePresence>
  130. </DocsWrapper>
  131. ) : (
  132. <DocsWrapper key={props.platformDocs.html}>
  133. <Content
  134. dangerouslySetInnerHTML={{__html: props.platformDocs.html}}
  135. onMouseDown={() => {
  136. !interacted &&
  137. trackAdvancedAnalyticsEvent('growth.onboarding_wizard_interacted', {
  138. organization: props.organization,
  139. project_id: props.project.id,
  140. platform: props.platform || undefined,
  141. wizard_instructions: false,
  142. });
  143. setInteracted(true);
  144. }}
  145. />
  146. {missingExampleWarning()}
  147. </DocsWrapper>
  148. ));
  149. const loadingError = (
  150. <LoadingError
  151. message={t(
  152. 'Failed to load documentation for the %s platform.',
  153. props.project?.platform
  154. )}
  155. onRetry={props.onRetry}
  156. />
  157. );
  158. const currentPlatform = props.platform ?? props.project?.platform ?? 'other';
  159. return (
  160. <Fragment>
  161. <FullIntroduction
  162. currentPlatform={currentPlatform}
  163. organization={props.organization}
  164. />
  165. {getDynamicText({
  166. value: !props.hasError ? docs : loadingError,
  167. fixed: testOnlyAlert,
  168. })}
  169. </Fragment>
  170. );
  171. }
  172. function SetupDocs({
  173. organization,
  174. projects: rawProjects,
  175. search,
  176. loadingProjects,
  177. }: Props) {
  178. const api = useApi();
  179. const [clientState, setClientState] = usePersistedOnboardingState();
  180. const selectedPlatforms = clientState?.selectedPlatforms || [];
  181. const platformToProjectIdMap = clientState?.platformToProjectIdMap || {};
  182. // id is really slug here
  183. const projectSlugs = selectedPlatforms
  184. .map(platform => platformToProjectIdMap[platform])
  185. .filter((slug): slug is string => slug !== undefined);
  186. const selectedProjectsSet = new Set(projectSlugs);
  187. // get projects in the order they appear in selectedPlatforms
  188. const projects = projectSlugs
  189. .map(slug => rawProjects.find(project => project.slug === slug))
  190. .filter((project): project is Project => project !== undefined);
  191. // SDK instrumentation
  192. const [hasError, setHasError] = useState(false);
  193. const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
  194. const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
  195. // store what projects have sent first event in state based project.firstEvent
  196. const [hasFirstEventMap, setHasFirstEventMap] = useState<Record<string, boolean>>(
  197. projects.reduce((accum, project: Project) => {
  198. accum[project.id] = !!project.firstEvent;
  199. return accum;
  200. }, {} as Record<string, boolean>)
  201. );
  202. const checkProjectHasFirstEvent = (project: Project) => {
  203. return !!hasFirstEventMap[project.id];
  204. };
  205. const {project_id: rawProjectId} = qs.parse(search);
  206. const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
  207. const firstProjectNoError = projects.findIndex(
  208. p => selectedProjectsSet.has(p.slug) && !checkProjectHasFirstEvent(p)
  209. );
  210. // Select a project based on search params. If non exist, use the first project without first event.
  211. const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
  212. const project = projects[projectIndex];
  213. // find the next project that doesn't have a first event
  214. const nextProject = projects.find(
  215. (p, i) => i > projectIndex && !checkProjectHasFirstEvent(p)
  216. );
  217. const integrationSlug = project?.platform && platformToIntegrationMap[project.platform];
  218. const [integrationUseManualSetup, setIntegrationUseManualSetup] = useState(false);
  219. useEffect(() => {
  220. // should not redirect if we don't have an active client state or projects aren't loaded
  221. if (!clientState || loadingProjects) {
  222. return;
  223. }
  224. if (
  225. // If no projects remaining, then we can leave
  226. !project
  227. ) {
  228. browserHistory.push('/');
  229. }
  230. });
  231. const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
  232. const fetchData = useCallback(async () => {
  233. // TODO: add better error handling logic
  234. if (!project?.platform) {
  235. return;
  236. }
  237. if (integrationSlug && !integrationUseManualSetup) {
  238. setLoadedPlatform(project.platform);
  239. setPlatformDocs(null);
  240. setHasError(false);
  241. return;
  242. }
  243. try {
  244. const loadedDocs = await loadDocs(
  245. api,
  246. organization.slug,
  247. project.slug,
  248. project.platform
  249. );
  250. setPlatformDocs(loadedDocs);
  251. setLoadedPlatform(project.platform);
  252. setHasError(false);
  253. } catch (error) {
  254. setHasError(error);
  255. throw error;
  256. }
  257. }, [project, api, organization, integrationSlug, integrationUseManualSetup]);
  258. useEffect(() => {
  259. fetchData();
  260. }, [fetchData]);
  261. if (!project) {
  262. return null;
  263. }
  264. const setNewProject = (newProjectId: string) => {
  265. setLoadedPlatform(null);
  266. setPlatformDocs(null);
  267. setHasError(false);
  268. setIntegrationUseManualSetup(false);
  269. const searchParams = new URLSearchParams({
  270. sub_step: 'project',
  271. project_id: newProjectId,
  272. });
  273. browserHistory.push(`${window.location.pathname}?${searchParams}`);
  274. clientState &&
  275. setClientState({
  276. ...clientState,
  277. state: 'projects_selected',
  278. url: `setup-docs/?${searchParams}`,
  279. });
  280. };
  281. const selectProject = (newProjectId: string) => {
  282. const matchedProject = projects.find(p => p.id === newProjectId);
  283. trackAdvancedAnalyticsEvent('growth.onboarding_clicked_project_in_sidebar', {
  284. organization,
  285. platform: matchedProject?.platform || 'unknown',
  286. });
  287. setNewProject(newProjectId);
  288. };
  289. return (
  290. <Fragment>
  291. <Wrapper>
  292. <SidebarWrapper>
  293. <ProjectSidebarSection
  294. projects={projects}
  295. selectedPlatformToProjectIdMap={Object.fromEntries(
  296. selectedPlatforms.map(platform => [
  297. platform,
  298. platformToProjectIdMap[platform],
  299. ])
  300. )}
  301. activeProject={project}
  302. {...{checkProjectHasFirstEvent, selectProject}}
  303. />
  304. </SidebarWrapper>
  305. <MainContent>
  306. {integrationSlug && !integrationUseManualSetup ? (
  307. <IntegrationSetup
  308. integrationSlug={integrationSlug}
  309. project={project}
  310. onClickManualSetup={() => {
  311. setIntegrationUseManualSetup(true);
  312. }}
  313. />
  314. ) : (
  315. <ProjecDocs
  316. platform={loadedPlatform}
  317. organization={organization}
  318. project={project}
  319. hasError={hasError}
  320. platformDocs={platformDocs}
  321. onRetry={fetchData}
  322. />
  323. )}
  324. </MainContent>
  325. </Wrapper>
  326. {project && (
  327. <FirstEventFooter
  328. project={project}
  329. organization={organization}
  330. isLast={!nextProject}
  331. hasFirstEvent={checkProjectHasFirstEvent(project)}
  332. onClickSetupLater={() => {
  333. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}`;
  334. trackAdvancedAnalyticsEvent(
  335. 'growth.onboarding_clicked_setup_platform_later',
  336. {
  337. organization,
  338. platform: currentPlatform,
  339. project_index: projectIndex,
  340. }
  341. );
  342. if (!project.platform || !clientState) {
  343. browserHistory.push(orgIssuesURL);
  344. return;
  345. }
  346. // if we have a next project, switch to that
  347. if (nextProject) {
  348. setNewProject(nextProject.id);
  349. } else {
  350. setClientState({
  351. ...clientState,
  352. state: 'finished',
  353. });
  354. browserHistory.push(orgIssuesURL);
  355. }
  356. }}
  357. handleFirstIssueReceived={() => {
  358. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  359. setHasFirstEventMap(newHasFirstEventMap);
  360. }}
  361. />
  362. )}
  363. </Fragment>
  364. );
  365. }
  366. export default withProjects(SetupDocs);
  367. type AlertType = React.ComponentProps<typeof Alert>['type'];
  368. const getAlertSelector = (type: AlertType) =>
  369. type === 'muted' ? null : `.alert[level="${type}"], .alert-${type}`;
  370. const mapAlertStyles = (p: {theme: Theme}, type: AlertType) =>
  371. css`
  372. ${getAlertSelector(type)} {
  373. ${alertStyles({theme: p.theme, type})};
  374. display: block;
  375. }
  376. `;
  377. const AnimatedContentWrapper = styled(motion.div)`
  378. overflow: hidden;
  379. `;
  380. AnimatedContentWrapper.defaultProps = {
  381. initial: {
  382. height: 0,
  383. },
  384. animate: {
  385. height: 'auto',
  386. },
  387. exit: {
  388. height: 0,
  389. },
  390. };
  391. const Content = styled(motion.div)`
  392. h1,
  393. h2,
  394. h3,
  395. h4,
  396. h5,
  397. h6,
  398. p {
  399. margin-bottom: 18px;
  400. }
  401. div[data-language] {
  402. margin-bottom: ${space(2)};
  403. }
  404. code {
  405. font-size: 87.5%;
  406. color: ${p => p.theme.pink300};
  407. }
  408. pre code {
  409. color: inherit;
  410. font-size: inherit;
  411. white-space: pre;
  412. }
  413. h2 {
  414. font-size: 1.4em;
  415. }
  416. .alert h5 {
  417. font-size: 1em;
  418. margin-bottom: 0.625rem;
  419. }
  420. /**
  421. * XXX(epurkhiser): This comes from the doc styles and avoids bottom margin issues in alerts
  422. */
  423. .content-flush-bottom *:last-child {
  424. margin-bottom: 0;
  425. }
  426. ${p => Object.keys(p.theme.alert).map(type => mapAlertStyles(p, type as AlertType))}
  427. `;
  428. const DocsWrapper = styled(motion.div)``;
  429. DocsWrapper.defaultProps = {
  430. initial: {opacity: 0, y: 40},
  431. animate: {opacity: 1, y: 0},
  432. exit: {opacity: 0},
  433. };
  434. const Wrapper = styled('div')`
  435. display: flex;
  436. flex-direction: row;
  437. margin: ${space(2)};
  438. justify-content: center;
  439. `;
  440. const MainContent = styled('div')`
  441. max-width: 850px;
  442. min-width: 0;
  443. flex-grow: 1;
  444. `;
  445. // the number icon will be space(2) + 30px to the left of the margin of center column
  446. // so we need to offset the right margin by that much
  447. // also hide the sidebar if the screen is too small
  448. const SidebarWrapper = styled('div')`
  449. margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
  450. @media (max-width: 1150px) {
  451. display: none;
  452. }
  453. flex-basis: 240px;
  454. flex-grow: 0;
  455. flex-shrink: 0;
  456. min-width: 240px;
  457. `;