Browse Source

ref(onboarding): Add amplitude experiment to the onboarding - (#45908)

Priscila Oliveira 2 years ago
parent
commit
454727feee

+ 12 - 24
static/app/components/onboarding/footer.tsx

@@ -20,7 +20,6 @@ import {useQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 import GenericFooter from 'sentry/views/onboarding/components/genericFooter';
-import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton';
 import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
 
 export type OnboardingState = {
@@ -124,7 +123,8 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     trackAdvancedAnalyticsEvent('onboarding.first_error_received', {
       organization,
       new_organization: !!newOrg,
-      project_slug: projectSlug,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
     });
 
     onboardingContext.setProjectData({
@@ -142,6 +142,7 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     projectData,
     onboardingContext,
     projectSlug,
+    selectedProject,
   ]);
 
   useEffect(() => {
@@ -160,7 +161,8 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     trackAdvancedAnalyticsEvent('onboarding.first_error_processed', {
       organization,
       new_organization: !!newOrg,
-      project_slug: projectSlug,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
     });
 
     onboardingContext.setProjectData({
@@ -179,6 +181,7 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     projectId,
     onboardingContext,
     projectSlug,
+    selectedProject,
   ]);
 
   // The explore button is only showed if Sentry has not yet received any errors OR the issue is still being processed
@@ -193,7 +196,8 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
 
     trackAdvancedAnalyticsEvent('onboarding.explore_sentry_button_clicked', {
       organization,
-      project_slug: projectSlug,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
     });
 
     if (clientState) {
@@ -214,7 +218,7 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     clientState,
     router,
     setClientState,
-    projectSlug,
+    selectedProject,
   ]);
 
   const handleSkipOnboarding = useCallback(() => {
@@ -265,7 +269,8 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     trackAdvancedAnalyticsEvent('onboarding.view_error_button_clicked', {
       organization,
       new_organization: !!newOrg,
-      project_slug: projectSlug,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
     });
 
     if (clientState) {
@@ -287,7 +292,7 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
     setClientState,
     onboardingContext,
     projectId,
-    projectSlug,
+    selectedProject,
   ]);
 
   return (
@@ -322,23 +327,6 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
           <Button priority="primary" onClick={handleViewError}>
             {t('View Error')}
           </Button>
-        ) : organization.features.includes(
-            'onboarding-heartbeat-footer-with-view-sample-error'
-          ) ? (
-          <CreateSampleEventButton
-            project={selectedProject}
-            source="targted-onboarding-heartbeat-footer"
-            priority="primary"
-            onCreateSampleGroup={() => {
-              trackAdvancedAnalyticsEvent('onboarding.view_sample_error_button_clicked', {
-                new_organization: !!newOrg,
-                project_slug: projectSlug,
-                organization,
-              });
-            }}
-          >
-            {t('View Sample Error')}
-          </CreateSampleEventButton>
         ) : (
           <Button
             priority="primary"

+ 406 - 0
static/app/components/onboarding/footerWithViewSampleErrorButton.tsx

@@ -0,0 +1,406 @@
+import {useCallback, useContext, useEffect, useState} 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 {OnboardingContext} from 'sentry/components/onboarding/onboardingContext';
+import {IconCheckmark, IconCircle, IconRefresh} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import PreferencesStore from 'sentry/stores/preferencesStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import {space} from 'sentry/styles/space';
+import {Group, OnboardingStatus, Project} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import {useQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+import GenericFooter from 'sentry/views/onboarding/components/genericFooter';
+import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton';
+import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
+
+export type OnboardingState = {
+  status: OnboardingStatus;
+  firstIssueId?: string;
+};
+
+const DEFAULT_POLL_INTERVAL = 5000;
+
+type Props = Pick<RouteComponentProps<{}, {}>, 'router' | 'route' | 'location'> & {
+  projectSlug: Project['slug'];
+  newOrg?: boolean;
+  projectId?: Project['id'];
+};
+
+async function openChangeRouteModal({
+  clientState,
+  nextLocation,
+  router,
+  setClientState,
+}: {
+  clientState: ReturnType<typeof usePersistedOnboardingState>[0];
+  nextLocation: Location;
+  router: RouteComponentProps<{}, {}>['router'];
+  setClientState: ReturnType<typeof usePersistedOnboardingState>[1];
+}) {
+  const mod = await import('sentry/components/onboarding/changeRouteModal');
+
+  const {ChangeRouteModal} = mod;
+
+  openModal(deps => (
+    <ChangeRouteModal
+      {...deps}
+      router={router}
+      nextLocation={nextLocation}
+      clientState={clientState}
+      setClientState={setClientState}
+    />
+  ));
+}
+
+export function FooterWithViewSampleErrorButton({
+  projectSlug,
+  projectId,
+  router,
+  newOrg,
+}: Props) {
+  const organization = useOrganization();
+  const preferences = useLegacyStore(PreferencesStore);
+  const [firstError, setFirstError] = useState<string | null>(null);
+  const [firstIssue, setFirstIssue] = useState<Group | undefined>(undefined);
+  const [clientState, setClientState] = usePersistedOnboardingState();
+  const {projects} = useProjects();
+  const onboardingContext = useContext(OnboardingContext);
+  const projectData = projectId ? onboardingContext.data[projectId] : undefined;
+  const selectedProject = projects.find(project => project.slug === projectSlug);
+
+  useQuery<Project>([`/projects/${organization.slug}/${projectSlug}/`], {
+    staleTime: 0,
+    refetchInterval: DEFAULT_POLL_INTERVAL,
+    enabled:
+      !!projectSlug && !firstError && projectData?.status === OnboardingStatus.WAITING, // Fetch only if the project is available and we have not yet received an error,
+    onSuccess: data => {
+      setFirstError(data.firstEvent);
+    },
+  });
+
+  // Locate the projects first issue group. The project.firstEvent field will
+  // *not* include sample events, while just looking at the issues list will.
+  // We will wait until the project.firstEvent is set and then locate the
+  // event given that event datetime
+  useQuery<Group[]>([`/projects/${organization.slug}/${projectSlug}/issues/`], {
+    staleTime: 0,
+    enabled:
+      !!firstError && !firstIssue && projectData?.status === OnboardingStatus.PROCESSING, // Only fetch if an error event is received and we have not yet located the first issue,
+    onSuccess: data => {
+      setFirstIssue(data.find((issue: Group) => issue.firstSeen === firstError));
+    },
+  });
+
+  useEffect(() => {
+    if (!projectId || !!projectData) {
+      return;
+    }
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.WAITING,
+    });
+  }, [projectData, onboardingContext, projectSlug, projectId]);
+
+  useEffect(() => {
+    if (!projectId) {
+      return;
+    }
+
+    if (!firstError) {
+      return;
+    }
+
+    if (projectData?.status !== OnboardingStatus.WAITING) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.first_error_received', {
+      organization,
+      new_organization: !!newOrg,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
+    });
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.PROCESSING,
+    });
+
+    addSuccessMessage(t('First error received'));
+  }, [
+    firstError,
+    newOrg,
+    organization,
+    projectId,
+    projectData,
+    onboardingContext,
+    projectSlug,
+    selectedProject,
+  ]);
+
+  useEffect(() => {
+    if (!projectId) {
+      return;
+    }
+
+    if (!firstIssue) {
+      return;
+    }
+
+    if (projectData?.status !== OnboardingStatus.PROCESSING) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.first_error_processed', {
+      organization,
+      new_organization: !!newOrg,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
+    });
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.PROCESSED,
+      firstIssueId: firstIssue.id,
+    });
+
+    addSuccessMessage(t('First error processed'));
+  }, [
+    firstIssue,
+    newOrg,
+    organization,
+    projectData,
+    projectId,
+    onboardingContext,
+    projectSlug,
+    selectedProject,
+  ]);
+
+  const handleSkipOnboarding = useCallback(() => {
+    if (!projectId) {
+      return;
+    }
+
+    if (onboardingContext.data[projectId].status !== OnboardingStatus.WAITING) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
+      organization,
+      source: 'targeted_onboarding_first_event_footer',
+    });
+
+    const selectedProjectId = selectedProject?.id;
+
+    let pathname = `/organizations/${organization.slug}/issues/?`;
+    if (selectedProjectId) {
+      pathname += `project=${selectedProjectId}&`;
+    }
+
+    openChangeRouteModal({
+      router,
+      nextLocation: {
+        ...router.location,
+        pathname: (pathname += `referrer=onboarding-first-event-footer-skip`),
+      },
+      setClientState,
+      clientState,
+    });
+  }, [
+    router,
+    organization,
+    setClientState,
+    clientState,
+    selectedProject,
+    onboardingContext,
+    projectId,
+  ]);
+
+  const handleViewError = useCallback(() => {
+    if (!projectId) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.view_error_button_clicked', {
+      organization,
+      new_organization: !!newOrg,
+      project_id: projectId,
+      platform: selectedProject?.platform ?? 'other',
+    });
+
+    if (clientState) {
+      setClientState({
+        ...clientState,
+        state: 'finished',
+      });
+    }
+
+    router.push({
+      ...router.location,
+      pathname: `/organizations/${organization.slug}/issues/${onboardingContext.data[projectId].firstIssueId}/?referrer=onboarding-first-event-footer`,
+    });
+  }, [
+    organization,
+    newOrg,
+    router,
+    clientState,
+    setClientState,
+    onboardingContext,
+    projectId,
+    selectedProject,
+  ]);
+
+  return (
+    <Wrapper newOrg={!!newOrg} sidebarCollapsed={!!preferences.collapsed}>
+      <Column>
+        {projectData?.status === OnboardingStatus.WAITING && newOrg && (
+          <Button onClick={handleSkipOnboarding} priority="link">
+            {t('Skip Onboarding')}
+          </Button>
+        )}
+      </Column>
+      <StatusesColumn>
+        {projectData?.status === OnboardingStatus.WAITING ? (
+          <WaitingForErrorStatus>
+            <IconCircle size="sm" />
+            {t('Waiting for error')}
+          </WaitingForErrorStatus>
+        ) : projectData?.status === OnboardingStatus.PROCESSED ? (
+          <ErrorProcessedStatus>
+            <IconCheckmark isCircled size="sm" color="green300" />
+            {t('Error Processed!')}
+          </ErrorProcessedStatus>
+        ) : (
+          <ErrorProcessingStatus>
+            <RefreshIcon size="sm" />
+            {t('Processing error')}
+          </ErrorProcessingStatus>
+        )}
+      </StatusesColumn>
+      <ActionsColumn>
+        {projectData?.status === OnboardingStatus.PROCESSED ? (
+          <Button priority="primary" onClick={handleViewError}>
+            {t('View Error')}
+          </Button>
+        ) : (
+          <CreateSampleEventButton
+            project={selectedProject}
+            source="targted-onboarding-heartbeat-footer"
+            priority="primary"
+            onCreateSampleGroup={() => {
+              if (!projectId) {
+                return;
+              }
+              trackAdvancedAnalyticsEvent('onboarding.view_sample_error_button_clicked', {
+                new_organization: !!newOrg,
+                project_id: projectId,
+                platform: selectedProject?.platform ?? 'other',
+                organization,
+              });
+            }}
+          >
+            {t('View Sample Error')}
+          </CreateSampleEventButton>
+        )}
+      </ActionsColumn>
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled(GenericFooter, {
+  shouldForwardProp: prop => isPropValid(prop),
+})<{
+  newOrg: boolean;
+  sidebarCollapsed: boolean;
+}>`
+  display: none;
+  display: flex;
+  flex-direction: row;
+  padding: ${space(2)} ${space(4)};
+  justify-content: space-between;
+  align-items: center;
+
+  @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 Column = styled('div')`
+  display: flex;
+`;
+
+const StatusesColumn = styled('div')`
+  display: flex;
+  justify-content: center;
+`;
+
+const ActionsColumn = styled('div')`
+  display: none;
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    display: flex;
+    justify-content: flex-end;
+  }
+`;
+
+const WaitingForErrorStatus = styled('div')`
+  display: grid;
+  grid-template-columns: max-content max-content;
+  gap: ${space(0.75)};
+  align-items: center;
+  padding: ${space(1)} ${space(1.5)};
+  border: 1.5px solid ${p => p.theme.gray500};
+  border-radius: 76px;
+  color: ${p => p.theme.gray500};
+  line-height: ${p => p.theme.fontSizeLarge};
+`;
+
+const ErrorProcessingStatus = styled(WaitingForErrorStatus)`
+  border-color: ${p => p.theme.gray200};
+  color: ${p => p.theme.gray300};
+  position: relative;
+
+  @keyframes rotate {
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const ErrorProcessedStatus = styled(WaitingForErrorStatus)`
+  border-radius: 44px;
+  background: ${p => p.theme.inverted.background};
+  color: ${p => p.theme.inverted.textColor};
+`;
+
+const RefreshIcon = styled(IconRefresh)`
+  animation: rotate 1s linear infinite;
+`;

+ 10 - 5
static/app/utils/analytics/onboardingAnalyticsEvents.tsx

@@ -4,23 +4,28 @@ export type OnboardingEventParameters = {
     to: string;
   };
   'onboarding.explore_sentry_button_clicked': {
-    project_slug: string;
+    platform: string;
+    project_id: string;
   };
   'onboarding.first_error_processed': {
     new_organization: boolean;
-    project_slug: string;
+    platform: string;
+    project_id: string;
   };
   'onboarding.first_error_received': {
     new_organization: boolean;
-    project_slug: string;
+    platform: string;
+    project_id: string;
   };
   'onboarding.view_error_button_clicked': {
     new_organization: boolean;
-    project_slug: string;
+    platform: string;
+    project_id: string;
   };
   'onboarding.view_sample_error_button_clicked': {
     new_organization: boolean;
-    project_slug: string;
+    platform: string;
+    project_id: string;
   };
 };
 

+ 67 - 7
static/app/views/onboarding/setupDocs.tsx

@@ -10,6 +10,7 @@ import ExternalLink from 'sentry/components/links/externalLink';
 import LoadingError from 'sentry/components/loadingError';
 import {DocumentationWrapper} from 'sentry/components/onboarding/documentationWrapper';
 import {Footer} from 'sentry/components/onboarding/footer';
+import {FooterWithViewSampleErrorButton} from 'sentry/components/onboarding/footerWithViewSampleErrorButton';
 import {PlatformKey} from 'sentry/data/platformCategories';
 import platforms from 'sentry/data/platforms';
 import {t, tct} from 'sentry/locale';
@@ -19,6 +20,7 @@ import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAna
 import getDynamicText from 'sentry/utils/getDynamicText';
 import {platformToIntegrationMap} from 'sentry/utils/integrationUtil';
 import useApi from 'sentry/utils/useApi';
+import {useExperiment} from 'sentry/utils/useExperiment';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
@@ -28,7 +30,6 @@ import ProjectSidebarSection from './components/projectSidebarSection';
 import IntegrationSetup from './integrationSetup';
 import {StepProps} from './types';
 import {usePersistedOnboardingState} from './utils';
-
 /**
  * The documentation will include the following string should it be missing the
  * verification example, which currently a lot of docs are.
@@ -111,15 +112,21 @@ function SetupDocs({search, route, router, location}: Props) {
   const organization = useOrganization();
   const {projects: rawProjects} = useProjects();
   const [clientState, setClientState] = usePersistedOnboardingState();
-
-  const heartbeatFooter = !!organization?.features.includes(
-    'onboarding-heartbeat-footer'
+  const {logExperiment, experimentAssignment} = useExperiment(
+    'OnboardingNewFooterExperiment',
+    {
+      logExperimentOnMount: false,
+    }
   );
 
   const singleSelectPlatform = !!organization?.features.includes(
     'onboarding-remove-multiselect-platform'
   );
 
+  const heartbeatFooter = !!organization?.features.includes(
+    'onboarding-heartbeat-footer'
+  );
+
   const selectedPlatforms = clientState?.selectedPlatforms || [];
   const platformToProjectIdMap = clientState?.platformToProjectIdMap || {};
   // id is really slug here
@@ -197,6 +204,13 @@ function SetupDocs({search, route, router, location}: Props) {
     fetchData();
   }, [fetchData]);
 
+  // log experiment on mount if feature enabled
+  useEffect(() => {
+    if (heartbeatFooter) {
+      logExperiment();
+    }
+  }, [logExperiment, heartbeatFooter]);
+
   if (!project) {
     return null;
   }
@@ -267,8 +281,17 @@ function SetupDocs({search, route, router, location}: Props) {
         </MainContent>
       </Wrapper>
 
-      {project &&
-        (heartbeatFooter ? (
+      {heartbeatFooter ? (
+        experimentAssignment === 'variant2' ? (
+          <FooterWithViewSampleErrorButton
+            projectSlug={project.slug}
+            projectId={project.id}
+            route={route}
+            router={router}
+            location={location}
+            newOrg
+          />
+        ) : experimentAssignment === 'variant1' ? (
           <Footer
             projectSlug={project.slug}
             projectId={project.id}
@@ -313,7 +336,44 @@ function SetupDocs({search, route, router, location}: Props) {
               setHasFirstEventMap(newHasFirstEventMap);
             }}
           />
-        ))}
+        )
+      ) : (
+        <FirstEventFooter
+          project={project}
+          organization={organization}
+          isLast={!nextProject}
+          hasFirstEvent={checkProjectHasFirstEvent(project)}
+          onClickSetupLater={() => {
+            const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
+            trackAdvancedAnalyticsEvent(
+              'growth.onboarding_clicked_setup_platform_later',
+              {
+                organization,
+                platform: currentPlatform,
+                project_index: projectIndex,
+              }
+            );
+            if (!project.platform || !clientState) {
+              browserHistory.push(orgIssuesURL);
+              return;
+            }
+            // if we have a next project, switch to that
+            if (nextProject) {
+              setNewProject(nextProject.id);
+            } else {
+              setClientState({
+                ...clientState,
+                state: 'finished',
+              });
+              browserHistory.push(orgIssuesURL);
+            }
+          }}
+          handleFirstIssueReceived={() => {
+            const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
+            setHasFirstEventMap(newHasFirstEventMap);
+          }}
+        />
+      )}
     </Fragment>
   );
 }

+ 0 - 25
static/app/views/settings/project/dynamicSampling/dynamicSampling.tsx

@@ -19,7 +19,6 @@ import {DynamicSamplingBias, DynamicSamplingBiasType} from 'sentry/types/samplin
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
 import useApi from 'sentry/utils/useApi';
-import {useExperiment} from 'sentry/utils/useExperiment';
 import useOrganization from 'sentry/utils/useOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
@@ -62,12 +61,6 @@ export const knowDynamicSamplingBiases = {
 export function DynamicSampling({project}: Props) {
   const organization = useOrganization();
   const api = useApi();
-  const {logExperiment, experimentAssignment} = useExperiment(
-    'OnboardingNewFooterExperiment',
-    {
-      logExperimentOnMount: false,
-    }
-  );
 
   const hasTransactionNamePriorityFlag = organization.features.includes(
     'dynamic-sampling-transaction-name-priority'
@@ -75,14 +68,6 @@ export function DynamicSampling({project}: Props) {
   const hasAccess = organization.access.includes('project:write');
   const biases = project.dynamicSamplingBiases ?? [];
 
-  // log experiment on mount if feature enabled
-  useEffect(() => {
-    // we are testing this on the dynamic sampling page but it will be removed soon
-    if (organization?.features.includes('onboarding-heartbeat-footer')) {
-      logExperiment();
-    }
-  }, [logExperiment, organization?.features]);
-
   useEffect(() => {
     trackAdvancedAnalyticsEvent('dynamic_sampling_settings.viewed', {
       organization,
@@ -141,16 +126,6 @@ export function DynamicSampling({project}: Props) {
   return (
     <SentryDocumentTitle title={t('Dynamic Sampling')}>
       <Fragment>
-        {organization?.features.includes('onboarding-heartbeat-footer') && (
-          <div>
-            The Heartbeat is active and you are participating in the experiment: &nbsp;
-            {experimentAssignment === 'baseline'
-              ? '(Baseline) No change'
-              : experimentAssignment === 'variant1'
-              ? '(Variant 1) New design with “Explore Sentry“ button disabled while “waiting for error“'
-              : '(Variant 2) New design with existing “View Sample Error“ button instead while “waiting for error“'}
-          </div>
-        )}
         <SettingsPageHeader
           title={
             <Fragment>