Browse Source

feat(profiling): onboarding boilerplate for profiling (#37068)

* ref(profiling): rename old onboarding to legacy

* feat(profiling): add onboarding boilerplate
Jonas 2 years ago
parent
commit
113efefa18

+ 157 - 0
static/app/components/profiling/ProfilingOnboarding/profilingOnboardingModal.tsx

@@ -0,0 +1,157 @@
+import {useCallback, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import Button, {ButtonPropsWithoutAriaLabel} from 'sentry/components/button';
+import {t} from 'sentry/locale';
+
+// This is just a doubly linked list of steps
+interface OnboardingStep {
+  current: React.ComponentType<OnboardingStepProps>;
+  next: OnboardingStep | null;
+  previous: OnboardingStep | null;
+}
+
+type OnboardingRouterState = [OnboardingStep, (step: OnboardingStep | null) => void];
+function useOnboardingRouter(initialStep: OnboardingStep): OnboardingRouterState {
+  const [state, setState] = useState(initialStep);
+
+  const toStep = useCallback((nextStep: OnboardingStep | null) => {
+    // For ergonomics, else we need to move everything to consts so that typescript can infer non nullable types
+    if (nextStep === null) {
+      return;
+    }
+
+    setState(current => {
+      const next = {...nextStep, next: null, previous: current};
+      // Add the edges between the old and the new step
+      current.next = next;
+      next.previous = current;
+      // Return the neext step
+      return next;
+    });
+  }, []);
+
+  return [state, toStep];
+}
+
+interface ProfilingOnboardingModalProps extends ModalRenderProps {}
+export function ProfilingOnboardingModal(props: ProfilingOnboardingModalProps) {
+  const [state, toStep] = useOnboardingRouter({
+    previous: null,
+    current: SelectProjectStep,
+    next: null,
+  });
+  return <state.current {...props} toStep={toStep} step={state} />;
+}
+
+// Individual modal steps are defined here.
+// We proxy the modal props to each individaul modal component
+// so that each can build their own modal and they can remain independent.
+interface OnboardingStepProps extends ModalRenderProps {
+  step: OnboardingStep;
+  toStep: OnboardingRouterState[1];
+}
+
+function SelectProjectStep({
+  Body: ModalBody,
+  Header: ModalHeader,
+  Footer: ModalFooter,
+  toStep,
+  step,
+}: OnboardingStepProps) {
+  const onNext = useCallback(
+    (platform: 'iOS' | 'Android') => {
+      toStep({
+        previous: step,
+        current:
+          platform === 'Android'
+            ? AndroidSendDebugFilesInstruction
+            : IOSSendDebugFilesInstruction,
+        next: null,
+      });
+    },
+    [step, toStep]
+  );
+
+  return (
+    <ModalBody>
+      <ModalHeader>Select a Project</ModalHeader>
+      <ModalFooter>
+        <ModalActions>
+          <NextStepButton onClick={() => onNext('Android')} />
+          <NextStepButton onClick={() => onNext('iOS')} />
+        </ModalActions>
+      </ModalFooter>
+    </ModalBody>
+  );
+}
+
+function AndroidSendDebugFilesInstruction({
+  Body: ModalBody,
+  Header: ModalHeader,
+  Footer: ModalFooter,
+  toStep,
+  step,
+}: OnboardingStepProps) {
+  return (
+    <ModalBody>
+      <ModalHeader>Send Debug Files For Android</ModalHeader>
+      <ModalFooter>
+        <ModalActions>
+          {step.previous ? (
+            <PreviousStepButton onClick={() => toStep(step.previous)} />
+          ) : null}
+        </ModalActions>
+      </ModalFooter>
+    </ModalBody>
+  );
+}
+
+function IOSSendDebugFilesInstruction({
+  Body: ModalBody,
+  Header: ModalHeader,
+  Footer: ModalFooter,
+  toStep,
+  step,
+}: OnboardingStepProps) {
+  // Required as typescript cannot properly infer the previous step
+  return (
+    <ModalBody>
+      <ModalHeader>Send Debug Files For iOS</ModalHeader>
+      <ModalFooter>
+        <ModalActions>
+          {step.previous !== null ? (
+            <PreviousStepButton onClick={() => toStep(step.previous)} />
+          ) : null}
+        </ModalActions>
+      </ModalFooter>
+    </ModalBody>
+  );
+}
+
+type StepButtonProps = Omit<ButtonPropsWithoutAriaLabel, 'children'>;
+// Common component definitions
+function NextStepButton(props: StepButtonProps) {
+  return (
+    <Button priority="primary" {...props}>
+      {t('Next')}
+    </Button>
+  );
+}
+
+function PreviousStepButton(props: StepButtonProps) {
+  return <Button {...props}>{t('Back')}</Button>;
+}
+
+interface ModalActionsProps {
+  children: React.ReactNode;
+}
+function ModalActions({children}: ModalActionsProps) {
+  return <ModalActionsContainer>{children}</ModalActionsContainer>;
+}
+
+const ModalActionsContainer = styled('div')`
+  display: flex;
+  justify-content: space-between;
+`;

+ 1 - 1
static/app/routes.tsx

@@ -1698,7 +1698,7 @@ function buildRoutes() {
       <IndexRoute component={make(() => import('sentry/views/profiling/content'))} />
       <Route
         path="onboarding/"
-        component={make(() => import('sentry/views/profiling/onboarding'))}
+        component={make(() => import('sentry/views/profiling/legacyOnboarding'))}
       />
       <Route
         path="summary/:projectId/"

+ 9 - 13
static/app/views/profiling/index.tsx

@@ -12,17 +12,9 @@ import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAna
 import useApi from 'sentry/utils/useApi';
 import withOrganization from 'sentry/utils/withOrganization';
 
-import ProfilingOnboarding from './profilingOnboarding';
+import LegacyProfilingOnboarding from './legacyProfilingOnboarding';
 
-function renderNoAccess() {
-  return (
-    <PageContent>
-      <Alert type="warning">{t("You don't have access to this feature")}</Alert>
-    </PageContent>
-  );
-}
-
-function shouldShowProfilingOnboarding(state: RequestState<PromptData>): boolean {
+function shouldShowLegacyProfilingOnboarding(state: RequestState<PromptData>): boolean {
   if (state.type === 'resolved') {
     return typeof state.data?.dismissedTime !== 'number';
   }
@@ -92,12 +84,16 @@ function ProfilingContainer({organization, children}: Props) {
       hookName="feature-disabled:profiling-page"
       features={['profiling']}
       organization={organization}
-      renderDisabled={renderNoAccess}
+      renderDisabled={() => (
+        <PageContent>
+          <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+        </PageContent>
+      )}
     >
       {requestState.type === 'loading' ? (
         <LoadingIndicator />
-      ) : shouldShowProfilingOnboarding(requestState) ? (
-        <ProfilingOnboarding
+      ) : shouldShowLegacyProfilingOnboarding(requestState) ? (
+        <LegacyProfilingOnboarding
           organization={organization}
           onDismissClick={handleDismiss}
           onDoneClick={handleDone}

+ 3 - 3
static/app/views/profiling/onboarding.tsx → static/app/views/profiling/legacyOnboarding.tsx

@@ -4,9 +4,9 @@ import {browserHistory} from 'react-router';
 import {generateProfilingRoute} from 'sentry/utils/profiling/routes';
 import useOrganization from 'sentry/utils/useOrganization';
 
-import ProfilingOnboarding from './profilingOnboarding';
+import LegacyProfilingOnboarding from './legacyProfilingOnboarding';
 
-export default function Onboarding() {
+export default function LegacyOnboarding() {
   const organization = useOrganization();
 
   const onDismissClick = useCallback(() => {
@@ -14,7 +14,7 @@ export default function Onboarding() {
   }, [organization.slug]);
 
   return (
-    <ProfilingOnboarding
+    <LegacyProfilingOnboarding
       organization={organization}
       onDoneClick={onDismissClick}
       onDismissClick={onDismissClick}

+ 1 - 1
static/app/views/profiling/profilingOnboarding.tsx → static/app/views/profiling/legacyProfilingOnboarding.tsx

@@ -20,7 +20,7 @@ interface Props {
   organization: Organization;
 }
 
-export default function ProfilingOnboarding(props: Props) {
+export default function LegacyProfilingOnboarding(props: Props) {
   useEffect(() => {
     trackAdvancedAnalyticsEvent('profiling_views.onboarding', {
       organization: props.organization,

+ 32 - 0
tests/js/spec/components/profiling/profilingOnboarding/profilingOnboarding.spec.tsx

@@ -0,0 +1,32 @@
+import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {ProfilingOnboardingModal} from 'sentry/components/profiling/ProfilingOnboarding/profilingOnboardingModal';
+
+const MockRenderModalProps: ModalRenderProps = {
+  Body: ({children}) => <div>{children}</div>,
+  Header: ({children}) => <div>{children}</div>,
+  Footer: ({children}) => <div>{children}</div>,
+} as ModalRenderProps;
+
+describe('ProfilingOnboarding', function () {
+  it('renders default step', () => {
+    render(<ProfilingOnboardingModal {...MockRenderModalProps} />);
+    expect(screen.getByText(/Select a Project/i)).toBeInTheDocument();
+  });
+
+  it('goes to next step and previous step', () => {
+    render(<ProfilingOnboardingModal {...MockRenderModalProps} />);
+    // Next step
+    act(() => {
+      userEvent.click(screen.getAllByText('Next')[0]);
+    });
+    expect(screen.getByText(/Send Debug Files/i)).toBeInTheDocument();
+
+    // Previous step
+    act(() => {
+      userEvent.click(screen.getAllByText('Back')[0]);
+    });
+    expect(screen.getByText(/Select a Project/i)).toBeInTheDocument();
+  });
+});