footer.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import {useCallback, useContext, useEffect, useState} 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 {OnboardingContext} from 'sentry/components/onboarding/onboardingContext';
  11. import {IconCheckmark, IconCircle, IconRefresh} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import PreferencesStore from 'sentry/stores/preferencesStore';
  14. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  15. import {space} from 'sentry/styles/space';
  16. import {Group, OnboardingProjectStatus, Project} from 'sentry/types';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import useProjects from 'sentry/utils/useProjects';
  21. import GenericFooter from 'sentry/views/onboarding/components/genericFooter';
  22. import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
  23. export type OnboardingState = {
  24. status: OnboardingProjectStatus;
  25. firstIssueId?: string;
  26. };
  27. const DEFAULT_POLL_INTERVAL = 5000;
  28. type Props = Pick<RouteComponentProps<{}, {}>, 'router' | 'route' | 'location'> & {
  29. projectSlug: Project['slug'];
  30. newOrg?: boolean;
  31. projectId?: Project['id'];
  32. };
  33. async function openChangeRouteModal({
  34. clientState,
  35. nextLocation,
  36. router,
  37. setClientState,
  38. }: {
  39. clientState: ReturnType<typeof usePersistedOnboardingState>[0];
  40. nextLocation: Location;
  41. router: RouteComponentProps<{}, {}>['router'];
  42. setClientState: ReturnType<typeof usePersistedOnboardingState>[1];
  43. }) {
  44. const mod = await import('sentry/components/onboarding/changeRouteModal');
  45. const {ChangeRouteModal} = mod;
  46. openModal(deps => (
  47. <ChangeRouteModal
  48. {...deps}
  49. router={router}
  50. nextLocation={nextLocation}
  51. clientState={clientState}
  52. setClientState={setClientState}
  53. />
  54. ));
  55. }
  56. export function Footer({projectSlug, projectId, router, newOrg}: Props) {
  57. const organization = useOrganization();
  58. const preferences = useLegacyStore(PreferencesStore);
  59. const [firstError, setFirstError] = useState<string | null>(null);
  60. const [firstIssue, setFirstIssue] = useState<Group | undefined>(undefined);
  61. const [clientState, setClientState] = usePersistedOnboardingState();
  62. const {projects} = useProjects();
  63. const onboardingContext = useContext(OnboardingContext);
  64. const projectData = projectId ? onboardingContext.data.projects[projectId] : undefined;
  65. const selectedProject = projects.find(project => project.slug === projectSlug);
  66. useApiQuery<Project>([`/projects/${organization.slug}/${projectSlug}/`], {
  67. staleTime: 0,
  68. refetchInterval: DEFAULT_POLL_INTERVAL,
  69. enabled:
  70. !!projectSlug &&
  71. !firstError &&
  72. projectData?.status === OnboardingProjectStatus.WAITING, // Fetch only if the project is available and we have not yet received an error,
  73. onSuccess: data => {
  74. setFirstError(data.firstEvent);
  75. },
  76. });
  77. // Locate the projects first issue group. The project.firstEvent field will
  78. // *not* include sample events, while just looking at the issues list will.
  79. // We will wait until the project.firstEvent is set and then locate the
  80. // event given that event datetime
  81. useApiQuery<Group[]>([`/projects/${organization.slug}/${projectSlug}/issues/`], {
  82. staleTime: 0,
  83. enabled:
  84. !!firstError &&
  85. !firstIssue &&
  86. projectData?.status === OnboardingProjectStatus.PROCESSING, // Only fetch if an error event is received and we have not yet located the first issue,
  87. onSuccess: data => {
  88. setFirstIssue(data.find((issue: Group) => issue.firstSeen === firstError));
  89. },
  90. });
  91. useEffect(() => {
  92. if (!projectId || !!projectData) {
  93. return;
  94. }
  95. onboardingContext.setProject({
  96. id: projectId,
  97. slug: projectSlug,
  98. status: OnboardingProjectStatus.WAITING,
  99. });
  100. }, [projectData, onboardingContext, projectSlug, projectId]);
  101. useEffect(() => {
  102. if (!projectId) {
  103. return;
  104. }
  105. if (!firstError) {
  106. return;
  107. }
  108. if (projectData?.status !== OnboardingProjectStatus.WAITING) {
  109. return;
  110. }
  111. trackAnalytics('onboarding.first_error_received', {
  112. organization,
  113. new_organization: !!newOrg,
  114. project_id: projectId,
  115. platform: selectedProject?.platform ?? 'other',
  116. });
  117. onboardingContext.setProject({
  118. id: projectId,
  119. slug: projectSlug,
  120. status: OnboardingProjectStatus.PROCESSING,
  121. });
  122. addSuccessMessage(t('First error received'));
  123. }, [
  124. firstError,
  125. newOrg,
  126. organization,
  127. projectId,
  128. projectData,
  129. onboardingContext,
  130. projectSlug,
  131. selectedProject,
  132. ]);
  133. useEffect(() => {
  134. if (!projectId) {
  135. return;
  136. }
  137. if (!firstIssue) {
  138. return;
  139. }
  140. if (projectData?.status !== OnboardingProjectStatus.PROCESSING) {
  141. return;
  142. }
  143. trackAnalytics('onboarding.first_error_processed', {
  144. organization,
  145. new_organization: !!newOrg,
  146. project_id: projectId,
  147. platform: selectedProject?.platform ?? 'other',
  148. });
  149. onboardingContext.setProject({
  150. id: projectId,
  151. slug: projectSlug,
  152. status: OnboardingProjectStatus.PROCESSED,
  153. firstIssueId: firstIssue.id,
  154. });
  155. addSuccessMessage(t('First error processed'));
  156. }, [
  157. firstIssue,
  158. newOrg,
  159. organization,
  160. projectData,
  161. projectId,
  162. onboardingContext,
  163. projectSlug,
  164. selectedProject,
  165. ]);
  166. // The explore button is only showed if Sentry has not yet received any errors OR the issue is still being processed
  167. const handleExploreSentry = useCallback(() => {
  168. if (!projectId) {
  169. return;
  170. }
  171. if (
  172. onboardingContext.data.projects[projectId].status ===
  173. OnboardingProjectStatus.WAITING
  174. ) {
  175. return;
  176. }
  177. trackAnalytics('onboarding.explore_sentry_button_clicked', {
  178. organization,
  179. project_id: projectId,
  180. platform: selectedProject?.platform ?? 'other',
  181. });
  182. if (clientState) {
  183. setClientState({
  184. ...clientState,
  185. state: 'finished',
  186. });
  187. }
  188. router.push({
  189. ...router.location,
  190. pathname: `/organizations/${organization.slug}/issues/?referrer=onboarding-first-event-footer`,
  191. });
  192. }, [
  193. organization,
  194. projectId,
  195. onboardingContext,
  196. clientState,
  197. router,
  198. setClientState,
  199. selectedProject,
  200. ]);
  201. const handleSkipOnboarding = useCallback(() => {
  202. if (!projectId) {
  203. return;
  204. }
  205. if (
  206. onboardingContext.data.projects[projectId].status !==
  207. OnboardingProjectStatus.WAITING
  208. ) {
  209. return;
  210. }
  211. trackAnalytics('growth.onboarding_clicked_skip', {
  212. organization,
  213. source: 'targeted_onboarding_first_event_footer',
  214. });
  215. const selectedProjectId = selectedProject?.id;
  216. let pathname = `/organizations/${organization.slug}/issues/?`;
  217. if (selectedProjectId) {
  218. pathname += `project=${selectedProjectId}&`;
  219. }
  220. openChangeRouteModal({
  221. router,
  222. nextLocation: {
  223. ...router.location,
  224. pathname: (pathname += `referrer=onboarding-first-event-footer-skip`),
  225. },
  226. setClientState,
  227. clientState,
  228. });
  229. }, [
  230. router,
  231. organization,
  232. setClientState,
  233. clientState,
  234. selectedProject,
  235. onboardingContext,
  236. projectId,
  237. ]);
  238. const handleViewError = useCallback(() => {
  239. if (!projectId) {
  240. return;
  241. }
  242. trackAnalytics('onboarding.view_error_button_clicked', {
  243. organization,
  244. new_organization: !!newOrg,
  245. project_id: projectId,
  246. platform: selectedProject?.platform ?? 'other',
  247. });
  248. if (clientState) {
  249. setClientState({
  250. ...clientState,
  251. state: 'finished',
  252. });
  253. }
  254. router.push({
  255. ...router.location,
  256. pathname: `/organizations/${organization.slug}/issues/${onboardingContext.data.projects[projectId].firstIssueId}/?referrer=onboarding-first-event-footer`,
  257. });
  258. }, [
  259. organization,
  260. newOrg,
  261. router,
  262. clientState,
  263. setClientState,
  264. onboardingContext,
  265. projectId,
  266. selectedProject,
  267. ]);
  268. return (
  269. <Wrapper newOrg={!!newOrg} sidebarCollapsed={!!preferences.collapsed}>
  270. <Column>
  271. {projectData?.status === OnboardingProjectStatus.WAITING && newOrg && (
  272. <Button onClick={handleSkipOnboarding} priority="link">
  273. {t('Skip Onboarding')}
  274. </Button>
  275. )}
  276. </Column>
  277. <StatusesColumn>
  278. {projectData?.status === OnboardingProjectStatus.WAITING ? (
  279. <WaitingForErrorStatus>
  280. <IconCircle size="sm" />
  281. {t('Waiting for error')}
  282. </WaitingForErrorStatus>
  283. ) : projectData?.status === OnboardingProjectStatus.PROCESSED ? (
  284. <ErrorProcessedStatus>
  285. <IconCheckmark isCircled size="sm" color="green300" />
  286. {t('Error Processed!')}
  287. </ErrorProcessedStatus>
  288. ) : (
  289. <ErrorProcessingStatus>
  290. <RefreshIcon size="sm" />
  291. {t('Processing error')}
  292. </ErrorProcessingStatus>
  293. )}
  294. </StatusesColumn>
  295. <ActionsColumn>
  296. {projectData?.status === OnboardingProjectStatus.PROCESSED ? (
  297. <Button priority="primary" onClick={handleViewError}>
  298. {t('View Error')}
  299. </Button>
  300. ) : (
  301. <Button
  302. priority="primary"
  303. disabled={projectData?.status === OnboardingProjectStatus.WAITING}
  304. onClick={handleExploreSentry}
  305. title={
  306. projectData?.status === OnboardingProjectStatus.WAITING
  307. ? t('Waiting for error')
  308. : undefined
  309. }
  310. >
  311. {t('Explore Sentry')}
  312. </Button>
  313. )}
  314. </ActionsColumn>
  315. </Wrapper>
  316. );
  317. }
  318. const Wrapper = styled(GenericFooter, {
  319. shouldForwardProp: prop => isPropValid(prop),
  320. })<{
  321. newOrg: boolean;
  322. sidebarCollapsed: boolean;
  323. }>`
  324. display: none;
  325. display: flex;
  326. flex-direction: row;
  327. padding: ${space(2)} ${space(4)};
  328. justify-content: space-between;
  329. align-items: center;
  330. @media (min-width: ${p => p.theme.breakpoints.small}) {
  331. display: grid;
  332. grid-template-columns: repeat(3, 1fr);
  333. align-items: center;
  334. gap: ${space(3)};
  335. }
  336. ${p =>
  337. !p.newOrg &&
  338. css`
  339. @media (min-width: ${p.theme.breakpoints.medium}) {
  340. width: calc(
  341. 100% -
  342. ${p.theme.sidebar[p.sidebarCollapsed ? 'collapsedWidth' : 'expandedWidth']}
  343. );
  344. right: 0;
  345. left: auto;
  346. }
  347. `}
  348. `;
  349. const Column = styled('div')`
  350. display: flex;
  351. `;
  352. const StatusesColumn = styled('div')`
  353. display: flex;
  354. justify-content: center;
  355. `;
  356. const ActionsColumn = styled('div')`
  357. display: none;
  358. @media (min-width: ${p => p.theme.breakpoints.small}) {
  359. display: flex;
  360. justify-content: flex-end;
  361. }
  362. `;
  363. const WaitingForErrorStatus = styled('div')`
  364. display: grid;
  365. grid-template-columns: max-content max-content;
  366. gap: ${space(0.75)};
  367. align-items: center;
  368. padding: ${space(1)} ${space(1.5)};
  369. border: 1.5px solid ${p => p.theme.gray500};
  370. border-radius: 76px;
  371. color: ${p => p.theme.gray500};
  372. line-height: ${p => p.theme.fontSizeLarge};
  373. `;
  374. const ErrorProcessingStatus = styled(WaitingForErrorStatus)`
  375. border-color: ${p => p.theme.gray200};
  376. color: ${p => p.theme.gray300};
  377. position: relative;
  378. @keyframes rotate {
  379. 100% {
  380. transform: rotate(360deg);
  381. }
  382. }
  383. `;
  384. const ErrorProcessedStatus = styled(WaitingForErrorStatus)`
  385. border-radius: 44px;
  386. background: ${p => p.theme.inverted.background};
  387. color: ${p => p.theme.inverted.textColor};
  388. `;
  389. const RefreshIcon = styled(IconRefresh)`
  390. animation: rotate 1s linear infinite;
  391. `;