footerWithViewSampleErrorButton.tsx 11 KB

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