footer.tsx 11 KB

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