Browse Source

feat(growth): end modal for sandbox walkthrough (#40687)

this pr has the end modal for the sandbox walkthrough project. once a
user finishes a tour, this modal will be shown asking them to sign up
for sentry, restart the tour, or to see the other offered tours.

<img width="517" alt="Screen Shot 2022-09-28 at 3 31 23 PM"
src="https://user-images.githubusercontent.com/46740234/198406327-b3850850-fb97-4464-bcdd-3c9da83f40ee.png">
Richard Roggenkemper 2 years ago
parent
commit
a2cd93d4cd

+ 14 - 1
static/app/actionCreators/guides.tsx

@@ -4,6 +4,9 @@ import {Client} from 'sentry/api';
 import ConfigStore from 'sentry/stores/configStore';
 import GuideStore from 'sentry/stores/guideStore';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import {getTour, isDemoWalkthrough} from 'sentry/utils/demoMode';
+
+import {demoEndModal} from './modal';
 
 const api = new Client();
 
@@ -47,7 +50,11 @@ export function dismissGuide(guide: string, step: number, orgId: string | null)
   closeGuide(true);
 }
 
-export function recordFinish(guide: string, orgId: string | null) {
+export function recordFinish(
+  guide: string,
+  orgId: string | null,
+  orgSlug: string | null
+) {
   api.request('/assistant/', {
     method: 'PUT',
     data: {
@@ -56,6 +63,12 @@ export function recordFinish(guide: string, orgId: string | null) {
     },
   });
 
+  const tour = getTour(guide);
+
+  if (isDemoWalkthrough() && tour) {
+    demoEndModal({tour, orgSlug});
+  }
+
   const user = ConfigStore.get('user');
   if (!user) {
     return;

+ 12 - 0
static/app/actionCreators/modal.tsx

@@ -275,6 +275,18 @@ export async function demoSignupModalV2(options: ModalOptions = {}) {
   openModal(deps => <Modal {...deps} {...options} />, {modalCss});
 }
 
+export type DemoEndModalOptions = {
+  orgSlug: string | null;
+  tour: string;
+};
+
+export async function demoEndModal(options: DemoEndModalOptions) {
+  const mod = await import('sentry/components/modals/demoEndModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
+}
+
 export async function openDashboardWidgetQuerySelectorModal(
   options: DashboardWidgetQuerySelectorModalOptions
 ) {

+ 0 - 2
static/app/bootstrap/exportGlobals.tsx

@@ -46,8 +46,6 @@ const SentryApp = {
   getModalPortal: require('sentry/utils/getModalPortal').default,
   Client: require('sentry/api').Client,
   IconArrow: require('sentry/icons/iconArrow').IconArrow,
-  IconClose: require('sentry/icons/iconClose').IconClose,
-  IconCheckmark: require('sentry/icons/iconCheckmark').IconCheckmark,
 };
 
 globals.SentryApp = SentryApp;

+ 5 - 2
static/app/components/assistant/guideAnchor.tsx

@@ -44,6 +44,7 @@ type Props = {
 type State = {
   active: boolean;
   orgId: string | null;
+  orgSlug: string | null;
   step: number;
   currentGuide?: Guide;
 };
@@ -53,6 +54,7 @@ class BaseGuideAnchor extends Component<Props, State> {
     active: false,
     step: 0,
     orgId: null,
+    orgSlug: null,
   };
 
   componentDidMount() {
@@ -96,6 +98,7 @@ class BaseGuideAnchor extends Component<Props, State> {
       currentGuide: data.currentGuide ?? undefined,
       step: data.currentStep,
       orgId: data.orgId,
+      orgSlug: data.orgSlug,
     });
   }
 
@@ -111,9 +114,9 @@ class BaseGuideAnchor extends Component<Props, State> {
     this.props.onStepComplete?.(e);
     this.props.onFinish?.(e);
 
-    const {currentGuide, orgId} = this.state;
+    const {currentGuide, orgId, orgSlug} = this.state;
     if (currentGuide) {
-      recordFinish(currentGuide.guide, orgId);
+      recordFinish(currentGuide.guide, orgId, orgSlug);
     }
     closeGuide();
   };

+ 67 - 0
static/app/components/modals/demoEndModal.spec.tsx

@@ -0,0 +1,67 @@
+import {renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import DemoEndModal from 'sentry/components/modals/demoEndModal';
+
+describe('DemoEndModal', function () {
+  const organization = TestStubs.Organization();
+
+  it('closes on close button click', function () {
+    const closeModal = jest.fn();
+
+    renderGlobalModal();
+
+    openModal(
+      modalProps => (
+        <DemoEndModal {...modalProps} orgSlug={organization.slug} tour="issues" />
+      ),
+      {onClose: closeModal}
+    );
+
+    userEvent.click(screen.getByRole('button', {name: 'Close Modal'}));
+    expect(closeModal).toHaveBeenCalled();
+  });
+
+  it('restarts tour on button click', function () {
+    const finishMock = MockApiClient.addMockResponse({
+      method: 'PUT',
+      url: '/assistant/',
+    });
+
+    // Tests that fetchGuide is being called when tour is restarted
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/assistant/',
+    });
+
+    renderGlobalModal();
+
+    openModal(modalProps => (
+      <DemoEndModal {...modalProps} orgSlug={organization.slug} tour="issues" />
+    ));
+
+    userEvent.click(screen.getByRole('button', {name: 'Restart Tour'}));
+    expect(finishMock).toHaveBeenCalledWith(
+      '/assistant/',
+      expect.objectContaining({
+        method: 'PUT',
+        data: {
+          guide: 'issues_v3',
+          status: 'restart',
+        },
+      })
+    );
+  });
+
+  it('opens sign up page on button click', function () {
+    renderGlobalModal();
+
+    openModal(modalProps => (
+      <DemoEndModal {...modalProps} orgSlug={organization.slug} tour="issues" />
+    ));
+
+    const signUpButton = screen.getByRole('button', {name: 'Sign up for Sentry'});
+    expect(signUpButton).toBeInTheDocument();
+    expect(signUpButton).toHaveAttribute('href', 'https://sentry.io/signup/');
+  });
+});

+ 168 - 0
static/app/components/modals/demoEndModal.tsx

@@ -0,0 +1,168 @@
+import React, {useCallback} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {fetchGuides} from 'sentry/actionCreators/guides';
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import Button from 'sentry/components/button';
+import ModalTask from 'sentry/components/onboardingWizard/modalTask';
+import {SidebarPanelKey} from 'sentry/components/sidebar/types';
+import {IconClose} from 'sentry/icons/iconClose';
+import {t} from 'sentry/locale';
+import SidebarPanelStore from 'sentry/stores/sidebarPanelStore';
+import {Organization} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+import {useNavigate} from 'sentry/utils/useNavigate';
+
+// tour is a string that tells which tour the user is completing in the walkthrough
+type Props = ModalRenderProps & {orgSlug: Organization['slug'] | null; tour: string};
+
+export default function DemoEndingModal({tour, closeModal, CloseButton, orgSlug}: Props) {
+  const api = useApi();
+  const navigate = useNavigate();
+
+  let cardTitle = '',
+    body = '',
+    guides = [''],
+    path = '';
+
+  switch (tour) {
+    case 'issues':
+      cardTitle = t('Issues Tour');
+      body = t(
+        'Thank you for completing the Issues tour. Learn about other Sentry features by starting another tour.'
+      );
+      guides = ['issues_v3', 'issue_stream_v3'];
+      path = `/organizations/${orgSlug}/issues/`;
+      break;
+    case 'performance':
+      cardTitle = t('Performance Tour');
+      body = t(
+        'Thank you for completing the Performance tour. Learn about other Sentry features by starting another tour.'
+      );
+      guides = ['performance', 'transaction_summary', 'transaction_details_v2'];
+      path = `/organizations/${orgSlug}/performance/`;
+      break;
+    case 'releases':
+      cardTitle = t('Releases Tour');
+      body = t(
+        'Thank you for completing the Releases tour. Learn about other Sentry features by starting another tour.'
+      );
+      guides = ['releases_v2', 'react-native-release', 'release-details_v2'];
+      path = `/organizations/${orgSlug}/releases/`;
+      break;
+    case 'tabs':
+      cardTitle = t('Check out the different tabs');
+      body = t(
+        'Thank you for checking out the different tabs. Learn about other Sentry features by starting another tour.'
+      );
+      guides = ['sidebar_v2'];
+      path = `/organizations/${orgSlug}/projects/`;
+      break;
+    default:
+  }
+
+  const sandboxData = window.SandboxData;
+  const url = sandboxData?.cta?.url || 'https://sentry.io/signup/';
+
+  const navigation = useCallback(() => {
+    navigate(path);
+  }, [path, navigate]);
+
+  async function handleRestart() {
+    await Promise.all(
+      guides.map(guide =>
+        api.requestPromise('/assistant/', {
+          method: 'PUT',
+          data: {guide, status: 'restart'},
+        })
+      )
+    );
+
+    closeModal?.();
+
+    fetchGuides();
+
+    navigation();
+  }
+
+  const handleMoreTours = () => {
+    closeModal?.();
+    SidebarPanelStore.togglePanel(SidebarPanelKey.OnboardingWizard);
+  };
+
+  return (
+    <EndModal>
+      <CloseButton
+        size="zero"
+        onClick={() => {
+          if (closeModal) {
+            closeModal();
+          }
+        }}
+        icon={<IconClose size="xs" />}
+      />
+      <ModalHeader>
+        <h2> {t('Tour Complete')} </h2>
+      </ModalHeader>
+      <ModalTask title={cardTitle} />
+      <ModalHeader>{body}</ModalHeader>
+      <ButtonContainer>
+        <SignUpButton external href={url}>
+          {sandboxData?.cta?.title || t('Sign up for Sentry')}
+        </SignUpButton>
+        <ButtonBar>
+          <Button onClick={handleMoreTours}>{t('More Tours')} </Button>
+          <Button onClick={handleRestart}>{t('Restart Tour')}</Button>
+        </ButtonBar>
+      </ButtonContainer>
+    </EndModal>
+  );
+}
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 500px;
+  [role='document'] {
+    position: relative;
+    padding: 50px 60px;
+  }
+`;
+
+const EndModal = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  align-items: center;
+`;
+
+const ModalHeader = styled('div')`
+  p {
+    font-size: 16px;
+    text-align: center;
+    margin: 0;
+  }
+  h2 {
+    font-size: 2em;
+    margin: 0;
+  }
+`;
+
+const SignUpButton = styled(Button)`
+  background-color: ${p => p.theme.purple300};
+  border: none;
+  color: ${p => p.theme.white};
+  width: 100%;
+`;
+
+const ButtonBar = styled('div')`
+  display: flex;
+  flex-direction: row;
+  gap: 5px;
+  justify-content: center;
+`;
+const ButtonContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+`;

+ 32 - 0
static/app/components/onboardingWizard/modalTask.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import Card from 'sentry/components/card';
+import {IconCheckmark} from 'sentry/icons/iconCheckmark';
+import space from 'sentry/styles/space';
+
+type Props = {title?: string};
+
+export default function ModalTask({title}: Props) {
+  return (
+    <TaskCard>
+      <Title>
+        {<IconCheckmark isCircled color="green300" />}
+        {title}
+      </Title>
+    </TaskCard>
+  );
+}
+
+const Title = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: ${space(1)};
+  align-items: center;
+  font-weight: 600;
+`;
+
+const TaskCard = styled(Card)`
+  position: relative;
+  padding: ${space(2)} ${space(3)};
+`;

+ 16 - 0
static/app/utils/demoMode.tsx

@@ -36,3 +36,19 @@ export function urlAttachQueryParams(url: string, params: URLSearchParams): stri
 export function isDemoWalkthrough(): boolean {
   return localStorage.getItem('new-walkthrough') === '1';
 }
+
+// Function to determine which tour has completed depending on the guide that is being passed in.
+export function getTour(guide: string): string | undefined {
+  switch (guide) {
+    case 'sidebar_v2':
+      return 'tabs';
+    case 'issues_v3':
+      return 'issues';
+    case 'release-details_v2':
+      return 'releases';
+    case 'transaction_details_v2':
+      return 'performance';
+    default:
+      return undefined;
+  }
+}