footerWithViewSampleErrorButton.tsx 11 KB

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