index.tsx 14 KB

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