setupDocs.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import 'prism-sentry/index.css';
  2. import {useEffect, useState} from 'react';
  3. import * as React from 'react';
  4. import {browserHistory} from 'react-router';
  5. import {css} from '@emotion/react';
  6. import styled from '@emotion/styled';
  7. import {motion} from 'framer-motion';
  8. import * as qs from 'query-string';
  9. import {loadDocs} from 'sentry/actionCreators/projects';
  10. import Alert, {alertStyles} from 'sentry/components/alert';
  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 {t, tct} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import {Project} from 'sentry/types';
  18. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  19. import getDynamicText from 'sentry/utils/getDynamicText';
  20. import {Theme} from 'sentry/utils/theme';
  21. import useApi from 'sentry/utils/useApi';
  22. import withProjects from 'sentry/utils/withProjects';
  23. import FirstEventFooter from './components/firstEventFooter';
  24. import FullIntroduction from './components/fullIntroduction';
  25. import TargetedOnboardingSidebar from './components/sidebar';
  26. import {StepProps} from './types';
  27. import {usePersistedOnboardingState} from './utils';
  28. /**
  29. * The documentation will include the following string should it be missing the
  30. * verification example, which currently a lot of docs are.
  31. */
  32. const INCOMPLETE_DOC_FLAG = 'TODO-ADD-VERIFICATION-EXAMPLE';
  33. type PlatformDoc = {html: string; link: string};
  34. type Props = {
  35. projects: Project[];
  36. search: string;
  37. } & StepProps;
  38. function SetupDocs({organization, projects, search}: Props) {
  39. const api = useApi();
  40. const [clientState, setClientState] = usePersistedOnboardingState();
  41. const selectedProjectsSet = new Set(
  42. clientState?.selectedPlatforms.map(
  43. platform => clientState.platformToProjectIdMap[platform]
  44. ) || []
  45. );
  46. const [hasError, setHasError] = useState(false);
  47. const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
  48. const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
  49. // store what projects have sent first event in state based project.firstEvent
  50. const [hasFirstEventMap, setHasFirstEventMap] = useState<Record<string, boolean>>(
  51. projects.reduce((accum, project: Project) => {
  52. accum[project.id] = !!project.firstEvent;
  53. return accum;
  54. }, {} as Record<string, boolean>)
  55. );
  56. const checkProjectHasFirstEvent = (project: Project) => {
  57. return !!hasFirstEventMap[project.id];
  58. };
  59. // TODO: Check no projects
  60. const {sub_step: rawSubStep, project_id: rawProjectId} = qs.parse(search);
  61. const subStep = rawSubStep === 'integration' ? 'integration' : 'project';
  62. const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
  63. const firstProjectNoError = projects.findIndex(
  64. p => selectedProjectsSet.has(p.slug) && !checkProjectHasFirstEvent(p)
  65. );
  66. // Select a project based on search params. If non exist, use the first project without first event.
  67. const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
  68. const project = projects[projectIndex];
  69. useEffect(() => {
  70. if (clientState && !project && projects.length > 0) {
  71. // Can't find a project to show, probably because all projects are either deleted or finished.
  72. browserHistory.push('/');
  73. }
  74. }, [clientState, project, projects]);
  75. const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
  76. const fetchData = async () => {
  77. // const {platform} = project || {};
  78. // TODO: add better error handling logic
  79. if (!project?.platform) {
  80. return;
  81. }
  82. try {
  83. const loadedDocs = await loadDocs(
  84. api,
  85. organization.slug,
  86. project.slug,
  87. project.platform
  88. );
  89. setPlatformDocs(loadedDocs);
  90. setLoadedPlatform(project.platform);
  91. setHasError(false);
  92. } catch (error) {
  93. setHasError(error);
  94. throw error;
  95. }
  96. };
  97. useEffect(() => {
  98. fetchData();
  99. }, [project]);
  100. // TODO: add better error handling logic
  101. if (!project && subStep === 'project') {
  102. return null;
  103. }
  104. const setNewProject = (newProjectId: string) => {
  105. const searchParams = new URLSearchParams({
  106. project_id: newProjectId,
  107. });
  108. browserHistory.push(`${window.location.pathname}?${searchParams}`);
  109. if (clientState) {
  110. setClientState({
  111. ...clientState,
  112. state: 'projects_selected',
  113. url: `setup-docs/?${searchParams}`,
  114. });
  115. }
  116. };
  117. const selectProject = (newProjectId: string) => {
  118. const matchedProject = projects.find(p => p.id === newProjectId);
  119. trackAdvancedAnalyticsEvent('growth.onboarding_clicked_project_in_sidebar', {
  120. organization,
  121. platform: matchedProject?.platform || 'unknown',
  122. });
  123. setNewProject(newProjectId);
  124. };
  125. const missingExampleWarning = () => {
  126. const missingExample =
  127. platformDocs && platformDocs.html.includes(INCOMPLETE_DOC_FLAG);
  128. if (!missingExample) {
  129. return null;
  130. }
  131. return (
  132. <Alert type="warning" showIcon>
  133. {tct(
  134. `Looks like this getting started example is still undergoing some
  135. work and doesn't include an example for triggering an event quite
  136. yet. If you have trouble sending your first event be sure to consult
  137. the [docsLink:full documentation] for [platform].`,
  138. {
  139. docsLink: <ExternalLink href={platformDocs?.link} />,
  140. platform: platforms.find(p => p.id === loadedPlatform)?.name,
  141. }
  142. )}
  143. </Alert>
  144. );
  145. };
  146. const docs = platformDocs !== null && (
  147. <DocsWrapper key={platformDocs.html}>
  148. <Content dangerouslySetInnerHTML={{__html: platformDocs.html}} />
  149. {missingExampleWarning()}
  150. </DocsWrapper>
  151. );
  152. const loadingError = (
  153. <LoadingError
  154. message={t('Failed to load documentation for the %s platform.', project.platform)}
  155. onRetry={fetchData}
  156. />
  157. );
  158. const testOnlyAlert = (
  159. <Alert type="warning">
  160. Platform documentation is not rendered in for tests in CI
  161. </Alert>
  162. );
  163. return (
  164. <React.Fragment>
  165. <Wrapper>
  166. <TargetedOnboardingSidebar
  167. projects={projects}
  168. selectedPlatformToProjectIdMap={
  169. clientState
  170. ? Object.fromEntries(
  171. clientState.selectedPlatforms.map(platform => [
  172. platform,
  173. clientState.platformToProjectIdMap[platform],
  174. ])
  175. )
  176. : {}
  177. }
  178. activeProject={project}
  179. {...{checkProjectHasFirstEvent, selectProject}}
  180. />
  181. <MainContent>
  182. <FullIntroduction currentPlatform={currentPlatform} />
  183. {getDynamicText({
  184. value: !hasError ? docs : loadingError,
  185. fixed: testOnlyAlert,
  186. })}
  187. </MainContent>
  188. </Wrapper>
  189. {project && (
  190. <FirstEventFooter
  191. project={project}
  192. organization={organization}
  193. isLast={
  194. !!clientState &&
  195. project.slug ===
  196. clientState.platformToProjectIdMap[
  197. clientState.selectedPlatforms[clientState.selectedPlatforms.length - 1]
  198. ]
  199. }
  200. hasFirstEvent={checkProjectHasFirstEvent(project)}
  201. onClickSetupLater={() => {
  202. const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}`;
  203. trackAdvancedAnalyticsEvent(
  204. 'growth.onboarding_clicked_setup_platform_later',
  205. {
  206. organization,
  207. platform: currentPlatform,
  208. project_index: projectIndex,
  209. }
  210. );
  211. if (!project.platform || !clientState) {
  212. browserHistory.push(orgIssuesURL);
  213. return;
  214. }
  215. const platformIndex = clientState.selectedPlatforms.indexOf(project.platform);
  216. const nextPlatform = clientState.selectedPlatforms[platformIndex + 1];
  217. const nextProjectSlug =
  218. nextPlatform && clientState.platformToProjectIdMap[nextPlatform];
  219. const nextProject = projects.find(p => p.slug === nextProjectSlug);
  220. if (!nextProject) {
  221. // We're done here.
  222. setClientState({
  223. ...clientState,
  224. state: 'finished',
  225. });
  226. // TODO: integrations
  227. browserHistory.push(orgIssuesURL);
  228. return;
  229. }
  230. setNewProject(nextProject.id);
  231. }}
  232. handleFirstIssueReceived={() => {
  233. const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
  234. setHasFirstEventMap(newHasFirstEventMap);
  235. }}
  236. />
  237. )}
  238. </React.Fragment>
  239. );
  240. }
  241. export default withProjects(SetupDocs);
  242. type AlertType = React.ComponentProps<typeof Alert>['type'];
  243. const getAlertSelector = (type: AlertType) =>
  244. type === 'muted' ? null : `.alert[level="${type}"], .alert-${type}`;
  245. const mapAlertStyles = (p: {theme: Theme}, type: AlertType) =>
  246. css`
  247. ${getAlertSelector(type)} {
  248. ${alertStyles({theme: p.theme, type})};
  249. display: block;
  250. }
  251. `;
  252. const Content = styled(motion.div)`
  253. h1,
  254. h2,
  255. h3,
  256. h4,
  257. h5,
  258. h6,
  259. p {
  260. margin-bottom: 18px;
  261. }
  262. div[data-language] {
  263. margin-bottom: ${space(2)};
  264. }
  265. code {
  266. font-size: 87.5%;
  267. color: ${p => p.theme.pink300};
  268. }
  269. pre code {
  270. color: inherit;
  271. font-size: inherit;
  272. white-space: pre;
  273. }
  274. h2 {
  275. font-size: 1.4em;
  276. }
  277. .alert h5 {
  278. font-size: 1em;
  279. margin-bottom: 0.625rem;
  280. }
  281. /**
  282. * XXX(epurkhiser): This comes from the doc styles and avoids bottom margin issues in alerts
  283. */
  284. .content-flush-bottom *:last-child {
  285. margin-bottom: 0;
  286. }
  287. ${p => Object.keys(p.theme.alert).map(type => mapAlertStyles(p, type as AlertType))}
  288. `;
  289. const DocsWrapper = styled(motion.div)``;
  290. DocsWrapper.defaultProps = {
  291. initial: {opacity: 0, y: 40},
  292. animate: {opacity: 1, y: 0},
  293. exit: {opacity: 0},
  294. };
  295. const Wrapper = styled('div')`
  296. display: flex;
  297. flex-direction: row;
  298. margin: ${space(2)};
  299. justify-content: center;
  300. `;
  301. const MainContent = styled('div')`
  302. max-width: 850px;
  303. min-width: 0;
  304. flex-grow: 1;
  305. `;