import {Fragment, useCallback, useEffect} from 'react'; import {RouteComponentProps} from 'react-router'; import isPropValid from '@emotion/is-prop-valid'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Location} from 'history'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import IdBadge from 'sentry/components/idBadge'; import Placeholder from 'sentry/components/placeholder'; import {IconCheckmark} from 'sentry/icons'; import {t} from 'sentry/locale'; import PreferencesStore from 'sentry/stores/preferencesStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import {space} from 'sentry/styles/space'; import {Project} from 'sentry/types'; import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; import GenericFooter from '../genericFooter'; import {useHeartbeat} from './useHeartbeat'; type HeartbeatState = { beats: { firstErrorReceived: boolean | string; sdkConnected: boolean; }; }; enum BeatStatus { AWAITING = 'awaiting', PENDING = 'pending', COMPLETE = 'complete', } async function openChangeRouteModal( router: RouteComponentProps<{}, {}>['router'], nextLocation: Location ) { const mod = await import( 'sentry/views/onboarding/components/heartbeatFooter/changeRouteModal' ); const {ChangeRouteModal} = mod; openModal(deps => ( )); } type Props = Pick, 'router' | 'route' | 'location'> & { projectSlug: Project['slug']; newOrg?: boolean; }; export function HeartbeatFooter({projectSlug, router, route, newOrg}: Props) { const organization = useOrganization(); const preferences = useLegacyStore(PreferencesStore); const {initiallyLoaded, fetchError, fetching, projects} = useProjects({ orgId: organization.id, slugs: [projectSlug], }); const projectsLoading = !initiallyLoaded && fetching; const project = !projectsLoading && !fetchError && projects.length ? projects.find(proj => proj.slug === projectSlug) : undefined; const heartbeat_sessionStorage_key = `heartbeat-${project?.slug}`; const [sessionStorage, setSessionStorage] = useSessionStorage( heartbeat_sessionStorage_key, { beats: { sdkConnected: false, firstErrorReceived: false, }, } ); const { loading, issuesLoading, firstErrorReceived, firstTransactionReceived, sessionReceived, serverConnected, } = useHeartbeat(project?.slug, project?.id); useEffect(() => { if (loading || !sessionReceived) { return; } trackAdvancedAnalyticsEvent('heartbeat.onboarding_session_received', { organization, new_organization: !!newOrg, }); }, [sessionReceived, loading, newOrg, organization]); useEffect(() => { if (loading || !firstTransactionReceived) { return; } trackAdvancedAnalyticsEvent('heartbeat.onboarding_first_transaction_received', { organization, new_organization: !!newOrg, }); }, [firstTransactionReceived, loading, newOrg, organization]); useEffect(() => { if (loading || !serverConnected || !!sessionStorage?.beats?.sdkConnected) { return; } setSessionStorage({ ...sessionStorage, beats: {...sessionStorage.beats, sdkConnected: true}, }); addSuccessMessage(t('SDK Connected')); }, [serverConnected, loading, sessionStorage, setSessionStorage]); useEffect(() => { if ( loading || issuesLoading || !firstErrorReceived || !!sessionStorage?.beats?.firstErrorReceived ) { return; } trackAdvancedAnalyticsEvent('heartbeat.onboarding_first_error_received', { organization, new_organization: !!newOrg, }); const firstErrorOrTrue = firstErrorReceived !== true && 'id' in firstErrorReceived ? firstErrorReceived.id : true; setSessionStorage({ ...sessionStorage, beats: {...sessionStorage.beats, firstErrorReceived: firstErrorOrTrue}, }); addSuccessMessage(t('First error received')); }, [ firstErrorReceived, issuesLoading, loading, newOrg, organization, sessionStorage, setSessionStorage, ]); useEffect(() => { const onUnload = (nextLocation?: Location) => { if (location.pathname.startsWith('onboarding')) { return true; } // If the user has not yet started with the onboarding, then we don't show the dialog if (!sessionStorage.beats.sdkConnected) { return true; } // If the user has already sent an error, then we don't show the dialog if (sessionStorage.beats.firstErrorReceived) { return true; } // Next Location is always available when user clicks on a item with a new route if (!nextLocation) { return true; } if (nextLocation.query.setUpRemainingOnboardingTasksLater) { return true; } // If users are in the onboarding of existing orgs && // have started the SDK instrumentation && // clicks elsewhere else to change the route, // then we display the 'are you sure?' dialog. openChangeRouteModal(router, nextLocation); return false; }; router.setRouteLeaveHook(route, onUnload); }, [ router, route, organization, sessionStorage.beats.sdkConnected, sessionStorage.beats.firstErrorReceived, ]); // The explore button is only showed if Sentry has not yet received any errors. const handleExploreSentry = useCallback(() => { trackAdvancedAnalyticsEvent('heartbeat.onboarding_explore_sentry_button_clicked', { organization, }); openChangeRouteModal(router, { ...router.location, pathname: `/organizations/${organization.slug}/issues/`, }); }, [router, organization]); // This button will go away in the next iteration, but // basically now it will display the 'are you sure?' dialog only // if Sentry has not yet received any errors. const handleGoToPerformance = useCallback(() => { trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_performance_button_clicked', { organization, }); const nextLocation: Location = { ...router.location, pathname: `/organizations/${organization.slug}/performance/`, query: {project: project?.id}, }; if (sessionStorage.beats.firstErrorReceived) { router.push(nextLocation); return; } openChangeRouteModal(router, nextLocation); }, [router, organization, project, sessionStorage.beats.firstErrorReceived]); // It's the same idea as the explore button and this will go away in the next iteration. const handleGoToIssues = useCallback(() => { trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_issues_button_clicked', { organization, }); openChangeRouteModal(router, { ...router.location, pathname: `/organizations/${organization.slug}/issues/`, query: {project: project?.id}, hash: '#welcome', }); }, [router, organization, project]); const handleGoToMyError = useCallback(() => { if (projectsLoading) { return; } trackAdvancedAnalyticsEvent('heartbeat.onboarding_go_to_my_error_button_clicked', { organization, new_organization: !!newOrg, }); if (typeof sessionStorage.beats.firstErrorReceived !== 'boolean') { router.push({ ...router.location, pathname: `/organizations/${organization.slug}/issues/${sessionStorage.beats.firstErrorReceived}/?referrer=onboarding-first-event-footer`, }); return; } router.push({ ...router.location, pathname: `/organizations/${organization.slug}/issues/?referrer=onboarding-first-event-footer`, }); }, [ projectsLoading, organization, newOrg, router, sessionStorage.beats.firstErrorReceived, ]); return ( {projectsLoading ? ( ) : ( )} {sessionStorage.beats.sdkConnected ? ( {t('SDK Connected')} ) : loading ? ( ) : ( 1 {t('Awaiting SDK connection')} )} {sessionStorage.beats.firstErrorReceived ? ( {t('First error received')} ) : loading ? ( ) : ( 2 {t('Awaiting first error')} )} {newOrg ? ( {sessionStorage.beats.firstErrorReceived && typeof sessionStorage.beats.firstErrorReceived !== 'boolean' ? ( ) : ( )} ) : ( {sessionStorage.beats.firstErrorReceived && typeof sessionStorage.beats.firstErrorReceived !== 'boolean' ? ( ) : ( )} )} ); } const Wrapper = styled(GenericFooter, { shouldForwardProp: prop => isPropValid(prop), })<{ newOrg: boolean; sidebarCollapsed: boolean; }>` display: none; display: flex; flex-direction: row; justify-content: flex-end; padding: ${space(2)} ${space(4)}; @media (min-width: ${p => p.theme.breakpoints.small}) { display: grid; grid-template-columns: repeat(3, 1fr); align-items: center; gap: ${space(3)}; } ${p => !p.newOrg && css` @media (min-width: ${p.theme.breakpoints.medium}) { width: calc( 100% - ${p.theme.sidebar[p.sidebarCollapsed ? 'collapsedWidth' : 'expandedWidth']} ); right: 0; left: auto; } `} `; const PlatformIconAndName = styled('div')` display: none; @media (min-width: ${p => p.theme.breakpoints.small}) { max-width: 100%; overflow: hidden; width: 100%; display: block; } `; const Beats = styled('div')` display: none; @media (min-width: ${p => p.theme.breakpoints.small}) { gap: ${space(2)}; display: grid; grid-template-columns: repeat(2, max-content); justify-content: center; align-items: center; } `; const LoadingPlaceholder = styled(Placeholder)` width: ${p => p.width ?? '100%'}; `; const PulsingIndicator = styled('div')` ${pulsingIndicatorStyles}; font-size: ${p => p.theme.fontSizeExtraSmall}; color: ${p => p.theme.white}; height: 16px; width: 16px; display: flex; align-items: center; justify-content: center; :before { top: auto; left: auto; } `; const Beat = styled('div')<{status: BeatStatus}>` width: 160px; display: flex; flex-direction: column; align-items: center; gap: ${space(0.5)}; font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.pink300}; ${p => p.status === BeatStatus.PENDING && css` color: ${p.theme.disabled}; ${PulsingIndicator} { background: ${p.theme.disabled}; :before { content: none; } } `} ${p => p.status === BeatStatus.COMPLETE && css` color: ${p.theme.successText}; ${PulsingIndicator} { background: ${p.theme.success}; :before { content: none; } } `} `; const Actions = styled('div')` display: flex; justify-content: flex-end; `;