Browse Source

feat(growth): add functionality for demo walkthrough tasks (#40947)

this pr gives the ability to use onboarding tasks for the demo
walkthrough. these tasks are similar to the onboarding ones but are
unique to users, not just organizations. the middleware in the Sandbox
intercepts the api calls for the onboarding tasks and creates the
Sandbox specific onboarding tasks. However, because these onboarding
tasks are not stored in the Organization object, separate functions
(that are hooked in from the Sandbox) are needed to retrieve them so
they can be displayed in the sidebar and sidebar panel.

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Richard Roggenkemper 2 years ago
parent
commit
ae1d30feaf

+ 9 - 4
static/app/actionCreators/guides.tsx

@@ -3,10 +3,12 @@ import * as Sentry from '@sentry/react';
 import {Client} from 'sentry/api';
 import ConfigStore from 'sentry/stores/configStore';
 import GuideStore from 'sentry/stores/guideStore';
+import {Organization} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import {getTour, isDemoWalkthrough} from 'sentry/utils/demoMode';
+import {getTourTask, isDemoWalkthrough} from 'sentry/utils/demoMode';
 
 import {demoEndModal} from './modal';
+import {updateOnboardingTask} from './onboardingTasks';
 
 const api = new Client();
 
@@ -53,7 +55,8 @@ export function dismissGuide(guide: string, step: number, orgId: string | null)
 export function recordFinish(
   guide: string,
   orgId: string | null,
-  orgSlug: string | null
+  orgSlug: string | null,
+  org: Organization | null
 ) {
   api.request('/assistant/', {
     method: 'PUT',
@@ -63,9 +66,11 @@ export function recordFinish(
     },
   });
 
-  const tour = getTour(guide);
+  const tourTask = getTourTask(guide);
 
-  if (isDemoWalkthrough() && tour) {
+  if (isDemoWalkthrough() && tourTask && org) {
+    const {tour, task} = tourTask;
+    updateOnboardingTask(api, org, {task, status: 'complete', completionSeen: true});
     demoEndModal({tour, orgSlug});
   }
 

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

@@ -45,6 +45,7 @@ const SentryApp = {
   Modal: require('sentry/actionCreators/modal'),
   getModalPortal: require('sentry/utils/getModalPortal').default,
   Client: require('sentry/api').Client,
+  // This is used in the Email Modal in the Sandbox
   IconArrow: require('sentry/icons/iconArrow').IconArrow,
 };
 

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

@@ -17,6 +17,7 @@ import {Body as HovercardBody, Hovercard} from 'sentry/components/hovercard';
 import {t, tct} from 'sentry/locale';
 import GuideStore, {GuideStoreState} from 'sentry/stores/guideStore';
 import space from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
 import theme from 'sentry/utils/theme';
 
 type Props = {
@@ -43,6 +44,7 @@ type Props = {
 
 type State = {
   active: boolean;
+  org: Organization | null;
   orgId: string | null;
   orgSlug: string | null;
   step: number;
@@ -55,6 +57,7 @@ class BaseGuideAnchor extends Component<Props, State> {
     step: 0,
     orgId: null,
     orgSlug: null,
+    org: null,
   };
 
   componentDidMount() {
@@ -99,6 +102,7 @@ class BaseGuideAnchor extends Component<Props, State> {
       step: data.currentStep,
       orgId: data.orgId,
       orgSlug: data.orgSlug,
+      org: data.organization,
     });
   }
 
@@ -114,9 +118,9 @@ class BaseGuideAnchor extends Component<Props, State> {
     this.props.onStepComplete?.(e);
     this.props.onFinish?.(e);
 
-    const {currentGuide, orgId, orgSlug} = this.state;
+    const {currentGuide, orgId, orgSlug, org} = this.state;
     if (currentGuide) {
-      recordFinish(currentGuide.guide, orgId, orgSlug);
+      recordFinish(currentGuide.guide, orgId, orgSlug, org);
     }
     closeGuide();
   };

+ 4 - 2
static/app/components/onboardingWizard/progressHeader.tsx

@@ -24,14 +24,16 @@ function ProgressHeader({allTasks, completedTasks}: Props) {
     description = t('Walk through this guide to get the most out of Sentry right away.');
   }
 
+  const filteredTasks = allTasks.filter(task => !task.renderCard);
+
   return (
     <Container>
       <StyledProgressRing
         size={80}
         barWidth={8}
-        text={allTasks.length - completedTasks.length}
+        text={filteredTasks.length - completedTasks.length}
         animateText
-        value={(completedTasks.length / allTasks.length) * 100}
+        value={(completedTasks.length / filteredTasks.length) * 100}
         progressEndcaps="round"
         backgroundColor={theme.gray100}
         textCss={() => css`

+ 33 - 17
static/app/components/onboardingWizard/sidebar.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useMemo, useRef} from 'react';
+import {useCallback, useEffect, useRef} from 'react';
 import styled from '@emotion/styled';
 import {AnimatePresence, motion} from 'framer-motion';
 
@@ -10,12 +10,14 @@ import {CommonSidebarProps} from 'sentry/components/sidebar/types';
 import Tooltip from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {OnboardingTask, OnboardingTaskKey, Project} from 'sentry/types';
+import {OnboardingTask, OnboardingTaskKey, Organization, Project} from 'sentry/types';
 import {isDemoWalkthrough} from 'sentry/utils/demoMode';
+import {useSandboxTasks} from 'sentry/utils/demoWalkthrough';
 import testableTransition from 'sentry/utils/testableTransition';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import withProjects from 'sentry/utils/withProjects';
+import {OnboardingState} from 'sentry/views/onboarding/types';
 import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
 
 import ProgressHeader from './progressHeader';
@@ -69,6 +71,29 @@ const upcomingTasksHeading = (
 );
 const completedTasksHeading = <Heading key="complete">{t('Completed')}</Heading>;
 
+export const useGetTasks = (
+  organization: Organization,
+  projects: Project[],
+  onboardingState: OnboardingState | null
+) => {
+  const callback = useCallback(() => {
+    const all = getMergedTasks({
+      organization,
+      projects,
+      onboardingState: onboardingState || undefined,
+    }).filter(task => task.display);
+    const filteredTasks = all.filter(task => !task.renderCard);
+    return {
+      allTasks: all,
+      customTasks: all.filter(task => task.renderCard),
+      active: filteredTasks.filter(findActiveTasks),
+      upcoming: filteredTasks.filter(findUpcomingTasks),
+      complete: filteredTasks.filter(findCompleteTasks),
+    };
+  }, [organization, projects, onboardingState]);
+  return isDemoWalkthrough() ? useSandboxTasks : callback;
+};
+
 function OnboardingWizardSidebar({collapsed, orientation, onClose, projects}: Props) {
   const api = useApi();
   const organization = useOrganization();
@@ -91,22 +116,13 @@ function OnboardingWizardSidebar({collapsed, orientation, onClose, projects}: Pr
       markCompletionSeenTimeout.current = window.setTimeout(resolve, time);
     });
   }
+  const getOnboardingTasks = useGetTasks(organization, projects, onboardingState);
 
-  const {allTasks, customTasks, active, upcoming, complete} = useMemo(() => {
-    const all = getMergedTasks({
-      organization,
-      projects,
-      onboardingState: onboardingState || undefined,
-    }).filter(task => task.display);
-    const tasks = all.filter(task => !task.renderCard);
-    return {
-      allTasks: all,
-      customTasks: all.filter(task => task.renderCard),
-      active: tasks.filter(findActiveTasks),
-      upcoming: tasks.filter(findUpcomingTasks),
-      complete: tasks.filter(findCompleteTasks),
-    };
-  }, [organization, projects, onboardingState]);
+  const {allTasks, customTasks, active, upcoming, complete} = getOnboardingTasks({
+    organization,
+    projects,
+    onboardingState: onboardingState || undefined,
+  });
 
   const markTasksAsSeen = useCallback(
     async function () {

+ 6 - 1
static/app/components/sidebar/onboardingStatus.tsx

@@ -15,6 +15,7 @@ import space from 'sentry/styles/space';
 import {OnboardingTaskStatus, Organization, Project} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import {isDemoWalkthrough} from 'sentry/utils/demoMode';
+import {useSandboxSidebarTasks} from 'sentry/utils/demoWalkthrough';
 import theme, {Theme} from 'sentry/utils/theme';
 import withProjects from 'sentry/utils/withProjects';
 import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
@@ -37,6 +38,10 @@ export const shouldShowSidebar = (organization: Organization) => {
   return featureHook(organization);
 };
 
+export const getSidebarTasks = isDemoWalkthrough()
+  ? useSandboxSidebarTasks
+  : getMergedTasks;
+
 const isDone = (task: OnboardingTaskStatus) =>
   task.status === 'complete' || task.status === 'skipped';
 
@@ -64,7 +69,7 @@ function OnboardingStatus({
     return null;
   }
 
-  const tasks = getMergedTasks({
+  const tasks = getSidebarTasks({
     organization: org,
     projects,
     onboardingState: onboardingState || undefined,

+ 6 - 0
static/app/stores/guideStore.tsx

@@ -54,6 +54,10 @@ export type GuideStoreState = {
    * Current organization slug
    */
   orgSlug: string | null;
+  /**
+   * Current Organization
+   */
+  organization: Organization | null;
   /**
    * The previously shown guide
    */
@@ -68,6 +72,7 @@ const defaultState: GuideStoreState = {
   currentStep: 0,
   orgId: null,
   orgSlug: null,
+  organization: null,
   forceShow: false,
   prevGuide: null,
 };
@@ -123,6 +128,7 @@ const storeConfig: GuideStoreDefinition = {
   setActiveOrganization(data: Organization) {
     this.state.orgId = data ? data.id : null;
     this.state.orgSlug = data ? data.slug : null;
+    this.state.organization = data ? data : null;
     this.updateCurrentGuide();
   },
 

+ 10 - 6
static/app/utils/demoMode.tsx

@@ -1,3 +1,5 @@
+import {OnboardingTaskKey} from 'sentry/types';
+
 export function extraQueryParameter(): URLSearchParams {
   const extraQueryString = window.SandboxData?.extraQueryString || '';
   const extraQuery = new URLSearchParams(extraQueryString);
@@ -31,23 +33,25 @@ export function urlAttachQueryParams(url: string, params: URLSearchParams): stri
   return url;
 }
 
-// For the Sandbox, we are testing a new walkthrough. This effects a few different components of Sentry including the Onboarding Sidebar, Onboarding Tasks, the Demo End Modal, Demo Sign Up Modal, Guides, and more.
+// For the Sandbox, we are testing a new walkthrough. This affects a few different components of Sentry including the Onboarding Sidebar, Onboarding Tasks, the Demo End Modal, Demo Sign Up Modal, Guides, and more.
 // Outside of the Sandbox, this should have no effect on other elements of Sentry.
 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 {
+export function getTourTask(
+  guide: string
+): {task: OnboardingTaskKey; tour: string} | undefined {
   switch (guide) {
     case 'sidebar_v2':
-      return 'tabs';
+      return {tour: 'tabs', task: OnboardingTaskKey.SIDEBAR_GUIDE};
     case 'issues_v3':
-      return 'issues';
+      return {tour: 'issues', task: OnboardingTaskKey.ISSUE_GUIDE};
     case 'release-details_v2':
-      return 'releases';
+      return {tour: 'releases', task: OnboardingTaskKey.RELEASE_GUIDE};
     case 'transaction_details_v2':
-      return 'performance';
+      return {tour: 'performance', task: OnboardingTaskKey.PERFORMANCE_GUIDE};
     default:
       return undefined;
   }

+ 144 - 0
static/app/utils/demoWalkthrough.tsx

@@ -0,0 +1,144 @@
+import {useEffect, useState} from 'react';
+
+import {getOnboardingTasks} from 'sentry/components/onboardingWizard/taskConfig';
+import {
+  findActiveTasks,
+  findCompleteTasks,
+  findUpcomingTasks,
+} from 'sentry/components/onboardingWizard/utils';
+import {OnboardingTask, Organization, Project} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+import {OnboardingState} from 'sentry/views/onboarding/types';
+
+type Options = {
+  onboardingState: OnboardingState | undefined;
+  organization: Organization;
+  projects: Project[];
+};
+
+/**
+ * This function is used to determine which tasks to show as complete/incomplete in the sidebar progress circle.
+ *
+ * TODO: Move these to the demo repo once we can use React Hooks in Hooks in the repo.
+ *
+ */
+export function useSandboxSidebarTasks({
+  organization,
+  projects,
+  onboardingState,
+}: Options) {
+  const api = useApi();
+
+  const [tasks, setTasks] = useState<OnboardingTask[]>([]);
+
+  useEffect(() => {
+    const getTasks = async () => {
+      const serverTasks = await api.requestPromise(
+        `/organizations/${organization.slug}/onboarding-tasks/`,
+        {method: 'GET'}
+      );
+
+      const taskDescriptors = getOnboardingTasks({
+        organization,
+        projects,
+        onboardingState,
+      });
+      // Map server task state (i.e. completed status) with tasks objects
+      const allTasks = taskDescriptors.map(
+        desc =>
+          ({
+            ...desc,
+            ...serverTasks.find(
+              serverTask =>
+                serverTask.task === desc.task || serverTask.task === desc.serverTask
+            ),
+            requisiteTasks: [],
+          } as OnboardingTask)
+      );
+
+      // Map incomplete requisiteTasks as full task objects
+      const mappedTasks = allTasks.map(task => ({
+        ...task,
+        requisiteTasks: task.requisites
+          .map(key => allTasks.find(task2 => task2.task === key)!)
+          .filter(reqTask => reqTask.status !== 'complete'),
+      }));
+
+      setTasks(mappedTasks);
+      return;
+    };
+    getTasks();
+  }, [organization, projects, onboardingState, api]);
+
+  return tasks;
+}
+
+/**
+ * This function is used to determine which onboarding task is shown in the Sidebar panel in the Sandbox.
+ *
+ * TODO: Move this to the demo repo once we can use React Hooks in Hooks in the repo.
+ *
+ */
+export function useSandboxTasks({organization, projects, onboardingState}: Options) {
+  const api = useApi();
+
+  const [allTasks, setAllTasks] = useState<OnboardingTask[]>([]);
+  const [customTasks, setCustomTasks] = useState<OnboardingTask[]>([]);
+  const [activeTasks, setActiveTasks] = useState<OnboardingTask[]>([]);
+  const [upcomingTasks, setUpcomingTasks] = useState<OnboardingTask[]>([]);
+  const [completeTasks, setCompleteTasks] = useState<OnboardingTask[]>([]);
+
+  useEffect(() => {
+    const getTasks = async () => {
+      const serverTasks = await api.requestPromise(
+        `/organizations/${organization.slug}/onboarding-tasks/`,
+        {method: 'GET'}
+      );
+
+      const taskDescriptors = getOnboardingTasks({
+        organization,
+        projects,
+        onboardingState,
+      });
+      // Map server task state (i.e. completed status) with tasks objects
+      const totalTasks = taskDescriptors.map(
+        desc =>
+          ({
+            ...desc,
+            ...serverTasks.find(
+              serverTask =>
+                serverTask.task === desc.task || serverTask.task === desc.serverTask
+            ),
+            requisiteTasks: [],
+          } as OnboardingTask)
+      );
+
+      // Map incomplete requisiteTasks as full task objects
+      const mappedTasks = totalTasks.map(task => ({
+        ...task,
+        requisiteTasks: task.requisites
+          .map(key => totalTasks.find(task2 => task2.task === key)!)
+          .filter(reqTask => reqTask.status !== 'complete'),
+      }));
+
+      const all = mappedTasks.filter(task => task.display);
+      const tasks = all.filter(task => !task.renderCard);
+
+      setAllTasks(all);
+      setCustomTasks(all.filter(task => task.renderCard));
+      setActiveTasks(tasks.filter(findActiveTasks));
+      setUpcomingTasks(tasks.filter(findUpcomingTasks));
+      setCompleteTasks(tasks.filter(findCompleteTasks));
+      return;
+    };
+    getTasks();
+  }, [organization, projects, onboardingState, api]);
+
+  return {
+    allTasks,
+    customTasks,
+    active: activeTasks,
+    upcoming: upcomingTasks,
+    complete: completeTasks,
+  };
+}