index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import {Fragment, useCallback, useEffect} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import isPropValid from '@emotion/is-prop-valid';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import {Location} from 'history';
  7. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  8. import {openModal} from 'sentry/actionCreators/modal';
  9. import {Button} from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import IdBadge from 'sentry/components/idBadge';
  12. import Placeholder from 'sentry/components/placeholder';
  13. import {IconCheckmark} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import PreferencesStore from 'sentry/stores/preferencesStore';
  16. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  17. import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator';
  18. import {space} from 'sentry/styles/space';
  19. import {Project} from 'sentry/types';
  20. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import useProjects from 'sentry/utils/useProjects';
  23. import {useSessionStorage} from 'sentry/utils/useSessionStorage';
  24. import GenericFooter from '../genericFooter';
  25. import {useHeartbeat} from './useHeartbeat';
  26. type HeartbeatState = {
  27. beats: {
  28. firstErrorReceived: boolean | string;
  29. sdkConnected: boolean;
  30. };
  31. };
  32. enum BeatStatus {
  33. AWAITING = 'awaiting',
  34. PENDING = 'pending',
  35. COMPLETE = 'complete',
  36. }
  37. async function openChangeRouteModal(
  38. router: RouteComponentProps<{}, {}>['router'],
  39. nextLocation: Location
  40. ) {
  41. const mod = await import(
  42. 'sentry/views/onboarding/components/heartbeatFooter/changeRouteModal'
  43. );
  44. const {ChangeRouteModal} = mod;
  45. openModal(deps => (
  46. <ChangeRouteModal {...deps} router={router} nextLocation={nextLocation} />
  47. ));
  48. }
  49. type Props = Pick<RouteComponentProps<{}, {}>, 'router' | 'route' | 'location'> & {
  50. projectSlug: Project['slug'];
  51. newOrg?: boolean;
  52. };
  53. export function HeartbeatFooter({projectSlug, router, route, newOrg}: Props) {
  54. const organization = useOrganization();
  55. const preferences = useLegacyStore(PreferencesStore);
  56. const {initiallyLoaded, fetchError, fetching, projects} = useProjects({
  57. orgId: organization.id,
  58. slugs: [projectSlug],
  59. });
  60. const projectsLoading = !initiallyLoaded && fetching;
  61. const project =
  62. !projectsLoading && !fetchError && projects.length
  63. ? projects.find(proj => proj.slug === projectSlug)
  64. : undefined;
  65. const heartbeat_sessionStorage_key = `heartbeat-${project?.slug}`;
  66. const [sessionStorage, setSessionStorage] = useSessionStorage<HeartbeatState>(
  67. heartbeat_sessionStorage_key,
  68. {
  69. beats: {
  70. sdkConnected: false,
  71. firstErrorReceived: false,
  72. },
  73. }
  74. );
  75. const {
  76. loading,
  77. issuesLoading,
  78. firstErrorReceived,
  79. firstTransactionReceived,
  80. sessionReceived,
  81. serverConnected,
  82. } = useHeartbeat(project?.slug, project?.id);
  83. useEffect(() => {
  84. if (loading || !sessionReceived) {
  85. return;
  86. }
  87. trackAdvancedAnalyticsEvent('heartbeat.onboarding_session_received', {
  88. organization,
  89. new_organization: !!newOrg,
  90. });
  91. }, [sessionReceived, loading, newOrg, organization]);
  92. useEffect(() => {
  93. if (loading || !firstTransactionReceived) {
  94. return;
  95. }
  96. trackAdvancedAnalyticsEvent('heartbeat.onboarding_first_transaction_received', {
  97. organization,
  98. new_organization: !!newOrg,
  99. });
  100. }, [firstTransactionReceived, loading, newOrg, organization]);
  101. useEffect(() => {
  102. if (loading || !serverConnected || !!sessionStorage?.beats?.sdkConnected) {
  103. return;
  104. }
  105. setSessionStorage({
  106. ...sessionStorage,
  107. beats: {...sessionStorage.beats, sdkConnected: true},
  108. });
  109. addSuccessMessage(t('SDK Connected'));
  110. }, [serverConnected, loading, sessionStorage, setSessionStorage]);
  111. useEffect(() => {
  112. if (
  113. loading ||
  114. issuesLoading ||
  115. !firstErrorReceived ||
  116. !!sessionStorage?.beats?.firstErrorReceived
  117. ) {
  118. return;
  119. }
  120. trackAdvancedAnalyticsEvent('heartbeat.onboarding_first_error_received', {
  121. organization,
  122. new_organization: !!newOrg,
  123. });
  124. const firstErrorOrTrue =
  125. firstErrorReceived !== true && 'id' in firstErrorReceived
  126. ? firstErrorReceived.id
  127. : true;
  128. setSessionStorage({
  129. ...sessionStorage,
  130. beats: {...sessionStorage.beats, firstErrorReceived: firstErrorOrTrue},
  131. });
  132. addSuccessMessage(t('First error received'));
  133. }, [
  134. firstErrorReceived,
  135. issuesLoading,
  136. loading,
  137. newOrg,
  138. organization,
  139. sessionStorage,
  140. setSessionStorage,
  141. ]);
  142. useEffect(() => {
  143. const onUnload = (nextLocation?: Location) => {
  144. if (location.pathname.startsWith('onboarding')) {
  145. return true;
  146. }
  147. // If the user has not yet started with the onboarding, then we don't show the dialog
  148. if (!sessionStorage.beats.sdkConnected) {
  149. return true;
  150. }
  151. // If the user has already sent an error, then we don't show the dialog
  152. if (sessionStorage.beats.firstErrorReceived) {
  153. return true;
  154. }
  155. // Next Location is always available when user clicks on a item with a new route
  156. if (!nextLocation) {
  157. return true;
  158. }
  159. if (nextLocation.query.setUpRemainingOnboardingTasksLater) {
  160. return true;
  161. }
  162. // If users are in the onboarding of existing orgs &&
  163. // have started the SDK instrumentation &&
  164. // clicks elsewhere else to change the route,
  165. // then we display the 'are you sure?' dialog.
  166. openChangeRouteModal(router, nextLocation);
  167. return false;
  168. };
  169. router.setRouteLeaveHook(route, onUnload);
  170. }, [
  171. router,
  172. route,
  173. organization,
  174. sessionStorage.beats.sdkConnected,
  175. sessionStorage.beats.firstErrorReceived,
  176. ]);
  177. // The explore button is only showed if Sentry has not yet received any errors.
  178. const handleExploreSentry = useCallback(() => {
  179. trackAdvancedAnalyticsEvent('heartbeat.onboarding_explore_sentry_button_clicked', {
  180. organization,
  181. });
  182. openChangeRouteModal(router, {
  183. ...router.location,
  184. pathname: `/organizations/${organization.slug}/issues/`,
  185. });
  186. }, [router, organization]);
  187. // This button will go away in the next iteration, but
  188. // basically now it will display the 'are you sure?' dialog only
  189. // if Sentry has not yet received any errors.
  190. const handleGoToPerformance = useCallback(() => {
  191. trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_performance_button_clicked', {
  192. organization,
  193. });
  194. const nextLocation: Location = {
  195. ...router.location,
  196. pathname: `/organizations/${organization.slug}/performance/`,
  197. query: {project: project?.id},
  198. };
  199. if (sessionStorage.beats.firstErrorReceived) {
  200. router.push(nextLocation);
  201. return;
  202. }
  203. openChangeRouteModal(router, nextLocation);
  204. }, [router, organization, project, sessionStorage.beats.firstErrorReceived]);
  205. // It's the same idea as the explore button and this will go away in the next iteration.
  206. const handleGoToIssues = useCallback(() => {
  207. trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_issues_button_clicked', {
  208. organization,
  209. });
  210. openChangeRouteModal(router, {
  211. ...router.location,
  212. pathname: `/organizations/${organization.slug}/issues/`,
  213. query: {project: project?.id},
  214. hash: '#welcome',
  215. });
  216. }, [router, organization, project]);
  217. const handleGoToMyError = useCallback(() => {
  218. if (projectsLoading) {
  219. return;
  220. }
  221. trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_my_error_button_clicked', {
  222. organization,
  223. new_organization: !!newOrg,
  224. });
  225. if (typeof sessionStorage.beats.firstErrorReceived !== 'boolean') {
  226. router.push({
  227. ...router.location,
  228. pathname: `/organizations/${organization.slug}/issues/${sessionStorage.beats.firstErrorReceived}/?referrer=onboarding-first-event-footer`,
  229. });
  230. return;
  231. }
  232. router.push({
  233. ...router.location,
  234. pathname: `/organizations/${organization.slug}/issues/?referrer=onboarding-first-event-footer`,
  235. });
  236. }, [
  237. projectsLoading,
  238. organization,
  239. newOrg,
  240. router,
  241. sessionStorage.beats.firstErrorReceived,
  242. ]);
  243. return (
  244. <Wrapper newOrg={!!newOrg} sidebarCollapsed={!!preferences.collapsed}>
  245. <PlatformIconAndName>
  246. {projectsLoading ? (
  247. <LoadingPlaceholder height="28px" width="276px" />
  248. ) : (
  249. <IdBadge
  250. project={project}
  251. displayPlatformName
  252. avatarSize={28}
  253. hideOverflow
  254. disableLink
  255. />
  256. )}
  257. </PlatformIconAndName>
  258. <Beats>
  259. {sessionStorage.beats.sdkConnected ? (
  260. <Beat status={BeatStatus.COMPLETE}>
  261. <IconCheckmark size="sm" isCircled />
  262. {t('SDK Connected')}
  263. </Beat>
  264. ) : loading ? (
  265. <LoadingPlaceholder height="28px" width="160px" />
  266. ) : (
  267. <Beat status={BeatStatus.AWAITING}>
  268. <PulsingIndicator>1</PulsingIndicator>
  269. {t('Awaiting SDK connection')}
  270. </Beat>
  271. )}
  272. {sessionStorage.beats.firstErrorReceived ? (
  273. <Beat status={BeatStatus.COMPLETE}>
  274. <IconCheckmark size="sm" isCircled />
  275. {t('First error received')}
  276. </Beat>
  277. ) : loading ? (
  278. <LoadingPlaceholder height="28px" width="160px" />
  279. ) : (
  280. <Beat status={BeatStatus.AWAITING}>
  281. <PulsingIndicator>2</PulsingIndicator>
  282. {t('Awaiting first error')}
  283. </Beat>
  284. )}
  285. </Beats>
  286. <Actions>
  287. <ButtonBar gap={1}>
  288. {newOrg ? (
  289. <Fragment>
  290. {sessionStorage.beats.firstErrorReceived &&
  291. typeof sessionStorage.beats.firstErrorReceived !== 'boolean' ? (
  292. <Button
  293. priority="primary"
  294. busy={projectsLoading}
  295. onClick={handleGoToMyError}
  296. >
  297. {t('Go to my error')}
  298. </Button>
  299. ) : (
  300. <Button
  301. priority="primary"
  302. busy={projectsLoading}
  303. onClick={handleExploreSentry}
  304. >
  305. {t('Explore Sentry')}
  306. </Button>
  307. )}
  308. </Fragment>
  309. ) : (
  310. <Fragment>
  311. <Button busy={projectsLoading} onClick={handleGoToPerformance}>
  312. {t('Go to Performance')}
  313. </Button>
  314. {sessionStorage.beats.firstErrorReceived &&
  315. typeof sessionStorage.beats.firstErrorReceived !== 'boolean' ? (
  316. <Button
  317. priority="primary"
  318. busy={projectsLoading}
  319. onClick={handleGoToMyError}
  320. >
  321. {t('Go to my error')}
  322. </Button>
  323. ) : (
  324. <Button
  325. priority="primary"
  326. busy={projectsLoading}
  327. onClick={handleGoToIssues}
  328. >
  329. {t('Go to Issues')}
  330. </Button>
  331. )}
  332. </Fragment>
  333. )}
  334. </ButtonBar>
  335. </Actions>
  336. </Wrapper>
  337. );
  338. }
  339. const Wrapper = styled(GenericFooter, {
  340. shouldForwardProp: prop => isPropValid(prop),
  341. })<{
  342. newOrg: boolean;
  343. sidebarCollapsed: boolean;
  344. }>`
  345. display: none;
  346. display: flex;
  347. flex-direction: row;
  348. justify-content: flex-end;
  349. padding: ${space(2)} ${space(4)};
  350. @media (min-width: ${p => p.theme.breakpoints.small}) {
  351. display: grid;
  352. grid-template-columns: repeat(3, 1fr);
  353. align-items: center;
  354. gap: ${space(3)};
  355. }
  356. ${p =>
  357. !p.newOrg &&
  358. css`
  359. @media (min-width: ${p.theme.breakpoints.medium}) {
  360. width: calc(
  361. 100% -
  362. ${p.theme.sidebar[p.sidebarCollapsed ? 'collapsedWidth' : 'expandedWidth']}
  363. );
  364. right: 0;
  365. left: auto;
  366. }
  367. `}
  368. `;
  369. const PlatformIconAndName = styled('div')`
  370. display: none;
  371. @media (min-width: ${p => p.theme.breakpoints.small}) {
  372. max-width: 100%;
  373. overflow: hidden;
  374. width: 100%;
  375. display: block;
  376. }
  377. `;
  378. const Beats = styled('div')`
  379. display: none;
  380. @media (min-width: ${p => p.theme.breakpoints.small}) {
  381. gap: ${space(2)};
  382. display: grid;
  383. grid-template-columns: repeat(2, max-content);
  384. justify-content: center;
  385. align-items: center;
  386. }
  387. `;
  388. const LoadingPlaceholder = styled(Placeholder)`
  389. width: ${p => p.width ?? '100%'};
  390. `;
  391. const PulsingIndicator = styled('div')`
  392. ${pulsingIndicatorStyles};
  393. font-size: ${p => p.theme.fontSizeExtraSmall};
  394. color: ${p => p.theme.white};
  395. height: 16px;
  396. width: 16px;
  397. display: flex;
  398. align-items: center;
  399. justify-content: center;
  400. :before {
  401. top: auto;
  402. left: auto;
  403. }
  404. `;
  405. const Beat = styled('div')<{status: BeatStatus}>`
  406. width: 160px;
  407. display: flex;
  408. flex-direction: column;
  409. align-items: center;
  410. gap: ${space(0.5)};
  411. font-size: ${p => p.theme.fontSizeSmall};
  412. color: ${p => p.theme.pink300};
  413. ${p =>
  414. p.status === BeatStatus.PENDING &&
  415. css`
  416. color: ${p.theme.disabled};
  417. ${PulsingIndicator} {
  418. background: ${p.theme.disabled};
  419. :before {
  420. content: none;
  421. }
  422. }
  423. `}
  424. ${p =>
  425. p.status === BeatStatus.COMPLETE &&
  426. css`
  427. color: ${p.theme.successText};
  428. ${PulsingIndicator} {
  429. background: ${p.theme.success};
  430. :before {
  431. content: none;
  432. }
  433. }
  434. `}
  435. `;
  436. const Actions = styled('div')`
  437. display: flex;
  438. justify-content: flex-end;
  439. `;