footer.tsx 10 KB

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