sidebar.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import {Fragment, ReactNode, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {PlatformIcon} from 'platformicons';
  4. import HighlightTopRightPattern from 'sentry-images/pattern/highlight-top-right.svg';
  5. import {Button} from 'sentry/components/button';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import IdBadge from 'sentry/components/idBadge';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import useOnboardingDocs from 'sentry/components/onboardingWizard/useOnboardingDocs';
  11. import {ReplayOnboardingLayout} from 'sentry/components/replaysOnboarding/replayOnboardingLayout';
  12. import useCurrentProjectState from 'sentry/components/replaysOnboarding/useCurrentProjectState';
  13. import useLoadOnboardingDoc from 'sentry/components/replaysOnboarding/useLoadOnboardingDoc';
  14. import {
  15. generateDocKeys,
  16. isPlatformSupported,
  17. replayJsFrameworkOptions,
  18. } from 'sentry/components/replaysOnboarding/utils';
  19. import {SegmentedControl} from 'sentry/components/segmentedControl';
  20. import {DocumentationWrapper} from 'sentry/components/sidebar/onboardingStep';
  21. import SidebarPanel from 'sentry/components/sidebar/sidebarPanel';
  22. import {CommonSidebarProps, SidebarPanelKey} from 'sentry/components/sidebar/types';
  23. import TextOverflow from 'sentry/components/textOverflow';
  24. import {Tooltip} from 'sentry/components/tooltip';
  25. import {
  26. backend,
  27. replayFrontendPlatforms,
  28. replayJsLoaderInstructionsPlatformList,
  29. replayPlatforms,
  30. } from 'sentry/data/platformCategories';
  31. import platforms, {otherPlatform} from 'sentry/data/platforms';
  32. import {t, tct} from 'sentry/locale';
  33. import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator';
  34. import {space} from 'sentry/styles/space';
  35. import {PlatformKey, Project, SelectValue} from 'sentry/types';
  36. import EventWaiter from 'sentry/utils/eventWaiter';
  37. import useApi from 'sentry/utils/useApi';
  38. import useOrganization from 'sentry/utils/useOrganization';
  39. import usePrevious from 'sentry/utils/usePrevious';
  40. import useUrlParams from 'sentry/utils/useUrlParams';
  41. function ReplaysOnboardingSidebar(props: CommonSidebarProps) {
  42. const {currentPanel, collapsed, hidePanel, orientation} = props;
  43. const organization = useOrganization();
  44. const isActive = currentPanel === SidebarPanelKey.REPLAYS_ONBOARDING;
  45. const hasProjectAccess = organization.access.includes('project:read');
  46. const newOnboarding = organization.features.includes('session-replay-new-zero-state');
  47. const {
  48. projects,
  49. allProjects,
  50. currentProject,
  51. setCurrentProject,
  52. supportedProjects,
  53. unsupportedProjects,
  54. } = useCurrentProjectState({
  55. currentPanel,
  56. });
  57. const projectSelectOptions = useMemo(() => {
  58. const supportedProjectItems: SelectValue<string>[] = supportedProjects
  59. .sort((aProject, bProject) => {
  60. // if we're comparing two projects w/ or w/o replays alphabetical sort
  61. if (aProject.hasReplays === bProject.hasReplays) {
  62. return aProject.slug.localeCompare(bProject.slug);
  63. }
  64. // otherwise sort by whether or not they have replays
  65. return aProject.hasReplays ? 1 : -1;
  66. })
  67. .map(project => {
  68. return {
  69. value: project.id,
  70. textValue: project.id,
  71. label: (
  72. <StyledIdBadge project={project} avatarSize={16} hideOverflow disableLink />
  73. ),
  74. };
  75. });
  76. const unsupportedProjectItems: SelectValue<string>[] = unsupportedProjects.map(
  77. project => {
  78. return {
  79. value: project.id,
  80. textValue: project.id,
  81. label: (
  82. <StyledIdBadge project={project} avatarSize={16} hideOverflow disableLink />
  83. ),
  84. disabled: true,
  85. };
  86. }
  87. );
  88. return [
  89. {
  90. label: t('Supported'),
  91. options: supportedProjectItems,
  92. },
  93. {
  94. label: t('Unsupported'),
  95. options: unsupportedProjectItems,
  96. },
  97. ];
  98. }, [supportedProjects, unsupportedProjects]);
  99. const showLoaderInstructions =
  100. currentProject &&
  101. currentProject.platform &&
  102. replayJsLoaderInstructionsPlatformList.includes(currentProject.platform);
  103. const defaultTab =
  104. currentProject && currentProject.platform && backend.includes(currentProject.platform)
  105. ? 'jsLoader'
  106. : 'npm';
  107. const {getParamValue: setupMode, setParamValue: setSetupMode} = useUrlParams(
  108. 'mode',
  109. defaultTab
  110. );
  111. const selectedProject = currentProject ?? projects[0] ?? allProjects[0];
  112. if (!isActive || !hasProjectAccess || !selectedProject) {
  113. return null;
  114. }
  115. return (
  116. <TaskSidebarPanel
  117. orientation={orientation}
  118. collapsed={collapsed}
  119. hidePanel={hidePanel}
  120. >
  121. <TopRightBackgroundImage src={HighlightTopRightPattern} />
  122. <TaskList>
  123. <Heading>{t('Getting Started with Session Replay')}</Heading>
  124. <HeaderActions>
  125. <div
  126. onClick={e => {
  127. // we need to stop bubbling the CompactSelect click event
  128. // failing to do so will cause the sidebar panel to close
  129. // the event.target will be unmounted by the time the panel listener
  130. // receives the event and assume the click was outside the panel
  131. e.stopPropagation();
  132. }}
  133. >
  134. <CompactSelect
  135. triggerLabel={
  136. currentProject ? (
  137. <StyledIdBadge
  138. project={currentProject}
  139. avatarSize={16}
  140. hideOverflow
  141. disableLink
  142. />
  143. ) : (
  144. t('Select a project')
  145. )
  146. }
  147. value={currentProject?.id}
  148. onChange={opt =>
  149. setCurrentProject(allProjects.find(p => p.id === opt.value))
  150. }
  151. triggerProps={{'aria-label': currentProject?.slug}}
  152. options={projectSelectOptions}
  153. position="bottom-end"
  154. />
  155. </div>
  156. {newOnboarding && showLoaderInstructions && (
  157. <SegmentedControl
  158. size="md"
  159. aria-label={t('Change setup method')}
  160. value={setupMode()}
  161. onChange={setSetupMode}
  162. >
  163. <SegmentedControl.Item key="npm">
  164. <StyledTooltip title={t('I have a JS Framework')} showOnlyOnOverflow>
  165. {t('I have a JS Framework')}
  166. </StyledTooltip>
  167. </SegmentedControl.Item>
  168. <SegmentedControl.Item key="jsLoader">
  169. <StyledTooltip title={t('I have an HTML Template')} showOnlyOnOverflow>
  170. {t('I have an HTML Template')}
  171. </StyledTooltip>
  172. </SegmentedControl.Item>
  173. </SegmentedControl>
  174. )}
  175. </HeaderActions>
  176. <OnboardingContent currentProject={selectedProject} />
  177. </TaskList>
  178. </TaskSidebarPanel>
  179. );
  180. }
  181. function OnboardingContent({currentProject}: {currentProject: Project}) {
  182. const jsFrameworkSelectOptions = replayJsFrameworkOptions.map(platform => {
  183. return {
  184. value: platform.id,
  185. textValue: platform.name,
  186. label: (
  187. <PlatformLabel>
  188. <PlatformIcon platform={platform.id} size={16} />
  189. <TextOverflow>{platform.name}</TextOverflow>
  190. </PlatformLabel>
  191. ),
  192. };
  193. });
  194. const api = useApi();
  195. const organization = useOrganization();
  196. const previousProject = usePrevious(currentProject);
  197. const [received, setReceived] = useState<boolean>(false);
  198. const {getParamValue: setupMode} = useUrlParams('mode');
  199. const [jsFramework, setJsFramework] = useState<{
  200. value: PlatformKey;
  201. label?: ReactNode;
  202. textValue?: string;
  203. }>(jsFrameworkSelectOptions[0]);
  204. const newOnboarding = organization.features.includes('session-replay-new-zero-state');
  205. const showJsFrameworkInstructions =
  206. newOnboarding &&
  207. currentProject &&
  208. currentProject.platform &&
  209. backend.includes(currentProject.platform) &&
  210. setupMode() === 'npm';
  211. const defaultTab =
  212. currentProject && currentProject.platform && backend.includes(currentProject.platform)
  213. ? 'jsLoader'
  214. : 'npm';
  215. const npmOnlyFramework =
  216. currentProject &&
  217. currentProject.platform &&
  218. replayFrontendPlatforms
  219. .filter(p => p !== 'javascript')
  220. .includes(currentProject.platform);
  221. useEffect(() => {
  222. if (previousProject.id !== currentProject.id) {
  223. setReceived(false);
  224. }
  225. }, [previousProject.id, currentProject.id]);
  226. const currentPlatform = currentProject.platform
  227. ? platforms.find(p => p.id === currentProject.platform) ?? otherPlatform
  228. : otherPlatform;
  229. const docKeys = useMemo(() => {
  230. return currentPlatform && !newOnboarding ? generateDocKeys(currentPlatform.id) : [];
  231. }, [currentPlatform, newOnboarding]);
  232. // Old onboarding docs
  233. const {docContents, isLoading, hasOnboardingContents} = useOnboardingDocs({
  234. project: currentProject,
  235. docKeys,
  236. isPlatformSupported: isPlatformSupported(currentPlatform),
  237. });
  238. // New onboarding docs
  239. const {
  240. docs: newDocs,
  241. dsn,
  242. cdn,
  243. isProjKeysLoading,
  244. } = useLoadOnboardingDoc({
  245. platform: showJsFrameworkInstructions
  246. ? replayJsFrameworkOptions.find(p => p.id === jsFramework.value) ??
  247. replayJsFrameworkOptions[0]
  248. : currentPlatform,
  249. organization,
  250. projectSlug: currentProject.slug,
  251. });
  252. if (isLoading || isProjKeysLoading) {
  253. return <LoadingIndicator />;
  254. }
  255. const doesNotSupportReplay = currentProject.platform
  256. ? !replayPlatforms.includes(currentProject.platform)
  257. : true;
  258. if (doesNotSupportReplay) {
  259. return (
  260. <Fragment>
  261. <div>
  262. {tct(
  263. 'Session Replay isn’t available for your [platform] project. It supports all browser JavaScript applications. It is built to work with @sentry/browser and our browser framework SDKs.',
  264. {platform: currentPlatform?.name || currentProject.slug}
  265. )}
  266. </div>
  267. <div>
  268. <Button
  269. size="sm"
  270. href="https://docs.sentry.io/platforms/javascript/session-replay/"
  271. external
  272. >
  273. {t('Go to Sentry Documentation')}
  274. </Button>
  275. </div>
  276. </Fragment>
  277. );
  278. }
  279. if (
  280. !currentPlatform ||
  281. (!newOnboarding && !hasOnboardingContents) ||
  282. (newOnboarding && !newDocs)
  283. ) {
  284. return (
  285. <Fragment>
  286. <div>
  287. {tct(
  288. 'Fiddlesticks. This checklist isn’t available for your [project] project yet, but for now, go to Sentry docs for installation details.',
  289. {project: currentProject.slug}
  290. )}
  291. </div>
  292. <div>
  293. <Button
  294. size="sm"
  295. href="https://docs.sentry.io/platforms/javascript/session-replay/"
  296. external
  297. >
  298. {t('Read Docs')}
  299. </Button>
  300. </div>
  301. </Fragment>
  302. );
  303. }
  304. return (
  305. <Fragment>
  306. <IntroText>
  307. {tct(
  308. `Adding Session Replay to your [platform] project is simple. Make sure you've got these basics down.`,
  309. {platform: currentPlatform?.name || currentProject.slug}
  310. )}
  311. {showJsFrameworkInstructions ? <div>{tct(
  312. `Also, ensure that you have set up trace propagation in your backend projects. To learn more, [link:read the docs].`,
  313. {link: <ExternalLink href="https://docs.sentry.io/product/session-replay/getting-started/#:~:text=Make%20sure%20you%27ve%20set%20up%20trace%20propagation%20in%20your%20backend%20projects." />}
  314. )}</div> : ''}
  315. {showJsFrameworkInstructions ? (
  316. <PlatformSelect>
  317. {t('Select your JS Framework: ')}
  318. <CompactSelect
  319. triggerLabel={jsFramework.label}
  320. value={jsFramework.value}
  321. onChange={v => {
  322. setJsFramework(v);
  323. }}
  324. options={jsFrameworkSelectOptions}
  325. position="bottom-end"
  326. />
  327. </PlatformSelect>
  328. ) : null}
  329. </IntroText>
  330. {newOnboarding && newDocs ? (
  331. <ReplayOnboardingLayout
  332. docsConfig={newDocs}
  333. dsn={dsn}
  334. cdn={cdn}
  335. activeProductSelection={[]}
  336. platformKey={currentPlatform.id}
  337. projectId={currentProject.id}
  338. projectSlug={currentProject.slug}
  339. configType={
  340. setupMode() === 'npm' || // switched to NPM tab
  341. (!setupMode() && defaultTab === 'npm') || // default value for FE frameworks when ?mode={...} in URL is not set yet
  342. npmOnlyFramework // even if '?mode=jsLoader', only show npm instructions for FE frameworks
  343. ? 'replayOnboardingNpm'
  344. : 'replayOnboardingJsLoader'
  345. }
  346. />
  347. ) : (
  348. docKeys.map((docKey, index) => {
  349. let footer: React.ReactNode = null;
  350. if (index === docKeys.length - 1) {
  351. footer = (
  352. <EventWaiter
  353. api={api}
  354. organization={organization}
  355. project={currentProject}
  356. eventType="replay"
  357. onIssueReceived={() => {
  358. setReceived(true);
  359. }}
  360. >
  361. {() =>
  362. received ? <EventReceivedIndicator /> : <EventWaitingIndicator />
  363. }
  364. </EventWaiter>
  365. );
  366. }
  367. return (
  368. <div key={index}>
  369. <OnboardingStepV2 step={index + 1} content={docContents[docKey]} />
  370. {footer}
  371. </div>
  372. );
  373. })
  374. )}
  375. </Fragment>
  376. );
  377. }
  378. // TODO: we'll have to move this into a folder for common consumption w/ Profiling, Performance etc.
  379. interface OnboardingStepV2Props {
  380. content: string;
  381. step: number;
  382. }
  383. function OnboardingStepV2({step, content}: OnboardingStepV2Props) {
  384. return (
  385. <OnboardingStepContainer>
  386. <div>
  387. <TaskStepNumber>{step}</TaskStepNumber>
  388. </div>
  389. <div>
  390. <DocumentationWrapper dangerouslySetInnerHTML={{__html: content}} />
  391. </div>
  392. </OnboardingStepContainer>
  393. );
  394. }
  395. const IntroText = styled('div')`
  396. padding-top: ${space(3)};
  397. display: grid;
  398. gap: ${space(1)};
  399. `;
  400. const OnboardingStepContainer = styled('div')`
  401. display: flex;
  402. & > :last-child {
  403. overflow: hidden;
  404. }
  405. `;
  406. const TaskStepNumber = styled('div')`
  407. display: flex;
  408. margin-right: ${space(1.5)};
  409. background-color: ${p => p.theme.yellow300};
  410. border-radius: 50%;
  411. font-weight: bold;
  412. height: ${space(4)};
  413. width: ${space(4)};
  414. justify-content: center;
  415. align-items: center;
  416. `;
  417. const TaskSidebarPanel = styled(SidebarPanel)`
  418. width: 600px;
  419. max-width: 100%;
  420. `;
  421. const TopRightBackgroundImage = styled('img')`
  422. position: absolute;
  423. top: 0;
  424. right: 0;
  425. width: 60%;
  426. user-select: none;
  427. `;
  428. const TaskList = styled('div')`
  429. display: grid;
  430. grid-auto-flow: row;
  431. grid-template-columns: 100%;
  432. gap: ${space(1)};
  433. margin: 50px ${space(4)} ${space(4)} ${space(4)};
  434. `;
  435. const Heading = styled('div')`
  436. display: flex;
  437. color: ${p => p.theme.activeText};
  438. font-size: ${p => p.theme.fontSizeExtraSmall};
  439. text-transform: uppercase;
  440. font-weight: 600;
  441. line-height: 1;
  442. margin-top: ${space(3)};
  443. `;
  444. const StyledIdBadge = styled(IdBadge)`
  445. overflow: hidden;
  446. white-space: nowrap;
  447. flex-shrink: 1;
  448. `;
  449. const PulsingIndicator = styled('div')`
  450. ${pulsingIndicatorStyles};
  451. margin-right: ${space(1)};
  452. `;
  453. const EventWaitingIndicator = styled((p: React.HTMLAttributes<HTMLDivElement>) => (
  454. <div {...p}>
  455. <PulsingIndicator />
  456. {t("Waiting for this project's first user session")}
  457. </div>
  458. ))`
  459. display: flex;
  460. align-items: center;
  461. flex-grow: 1;
  462. font-size: ${p => p.theme.fontSizeMedium};
  463. color: ${p => p.theme.pink400};
  464. `;
  465. const EventReceivedIndicator = styled((p: React.HTMLAttributes<HTMLDivElement>) => (
  466. <div {...p}>
  467. {'🎉 '}
  468. {t("We've received this project's first user session!")}
  469. </div>
  470. ))`
  471. display: flex;
  472. align-items: center;
  473. flex-grow: 1;
  474. font-size: ${p => p.theme.fontSizeMedium};
  475. color: ${p => p.theme.successText};
  476. `;
  477. const HeaderActions = styled('div')`
  478. display: flex;
  479. flex-direction: row;
  480. justify-content: space-between;
  481. gap: ${space(3)};
  482. `;
  483. const StyledTooltip = styled(Tooltip)`
  484. ${p => p.theme.overflowEllipsis};
  485. `;
  486. const PlatformLabel = styled('div')`
  487. display: flex;
  488. gap: ${space(1)};
  489. align-items: center;
  490. `;
  491. const PlatformSelect = styled('div')`
  492. display: flex;
  493. gap: ${space(1)};
  494. align-items: center;
  495. padding-bottom: ${space(1)};
  496. `;
  497. export default ReplaysOnboardingSidebar;