|
@@ -9,13 +9,14 @@ import * as qs from 'query-string';
|
|
|
|
|
|
import {loadDocs} from 'sentry/actionCreators/projects';
|
|
|
import Alert, {alertStyles} from 'sentry/components/alert';
|
|
|
+import AsyncComponent from 'sentry/components/asyncComponent';
|
|
|
import ExternalLink from 'sentry/components/links/externalLink';
|
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
|
import {PlatformKey} from 'sentry/data/platformCategories';
|
|
|
import platforms from 'sentry/data/platforms';
|
|
|
import {t, tct} from 'sentry/locale';
|
|
|
import space from 'sentry/styles/space';
|
|
|
-import {Project} from 'sentry/types';
|
|
|
+import {Integration, IntegrationProvider, Project} from 'sentry/types';
|
|
|
import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
|
|
|
import getDynamicText from 'sentry/utils/getDynamicText';
|
|
|
import {Theme} from 'sentry/utils/theme';
|
|
@@ -24,7 +25,10 @@ import withProjects from 'sentry/utils/withProjects';
|
|
|
|
|
|
import FirstEventFooter from './components/firstEventFooter';
|
|
|
import FullIntroduction from './components/fullIntroduction';
|
|
|
-import TargetedOnboardingSidebar from './components/sidebar';
|
|
|
+import IntegrationInstaller from './components/integrationInstaller';
|
|
|
+import IntegrationSidebarSection from './components/integrationSidebarSection';
|
|
|
+import ProjectSidebarSection from './components/projectSidebarSection';
|
|
|
+import SetupIntegrationsFooter from './components/setupIntegrationsFooter';
|
|
|
import {StepProps} from './types';
|
|
|
import {usePersistedOnboardingState} from './utils';
|
|
|
|
|
@@ -37,11 +41,21 @@ const INCOMPLETE_DOC_FLAG = 'TODO-ADD-VERIFICATION-EXAMPLE';
|
|
|
type PlatformDoc = {html: string; link: string};
|
|
|
|
|
|
type Props = {
|
|
|
+ configurations: Integration[];
|
|
|
projects: Project[];
|
|
|
+ providers: IntegrationProvider[];
|
|
|
search: string;
|
|
|
+ loadingProjects?: boolean;
|
|
|
} & StepProps;
|
|
|
|
|
|
-function SetupDocs({organization, projects, search}: Props) {
|
|
|
+function SetupDocs({
|
|
|
+ organization,
|
|
|
+ projects,
|
|
|
+ search,
|
|
|
+ configurations,
|
|
|
+ providers,
|
|
|
+ loadingProjects,
|
|
|
+}: Props) {
|
|
|
const api = useApi();
|
|
|
const [clientState, setClientState] = usePersistedOnboardingState();
|
|
|
const selectedProjectsSet = new Set(
|
|
@@ -50,6 +64,7 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
) || []
|
|
|
);
|
|
|
|
|
|
+ // SDK instrumentation
|
|
|
const [hasError, setHasError] = useState(false);
|
|
|
const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
|
|
|
const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
|
|
@@ -65,7 +80,11 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
};
|
|
|
|
|
|
// TODO: Check no projects
|
|
|
- const {sub_step: rawSubStep, project_id: rawProjectId} = qs.parse(search);
|
|
|
+ const {
|
|
|
+ sub_step: rawSubStep,
|
|
|
+ project_id: rawProjectId,
|
|
|
+ integration: rawActiveIntegration,
|
|
|
+ } = qs.parse(search);
|
|
|
const subStep = rawSubStep === 'integration' ? 'integration' : 'project';
|
|
|
const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
|
|
|
const firstProjectNoError = projects.findIndex(
|
|
@@ -73,14 +92,52 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
);
|
|
|
// Select a project based on search params. If non exist, use the first project without first event.
|
|
|
const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
|
|
|
- const project = projects[projectIndex];
|
|
|
+ const project = subStep === 'project' ? projects[projectIndex] : null;
|
|
|
+
|
|
|
+ // integration installation
|
|
|
+ const selectedIntegrations = clientState?.selectedIntegrations || [];
|
|
|
+ const [installedIntegrations, setInstalledIntegrations] = useState(
|
|
|
+ new Set(configurations.map(config => config.provider.slug))
|
|
|
+ );
|
|
|
+ const integrationsNotInstalled = selectedIntegrations.filter(
|
|
|
+ i => !installedIntegrations.has(i)
|
|
|
+ );
|
|
|
+ const setIntegrationInstalled = (integration: string) =>
|
|
|
+ setInstalledIntegrations(new Set([...installedIntegrations, integration]));
|
|
|
+
|
|
|
+ const rawActiveIntegrationIndex = selectedIntegrations.findIndex(
|
|
|
+ i => i === rawActiveIntegration
|
|
|
+ );
|
|
|
+
|
|
|
+ const firstUninstalledIntegrationIndex = selectedIntegrations.findIndex(
|
|
|
+ i => !installedIntegrations.has(i)
|
|
|
+ );
|
|
|
+
|
|
|
+ const activeIntegrationIndex =
|
|
|
+ rawActiveIntegrationIndex >= 0
|
|
|
+ ? rawActiveIntegrationIndex
|
|
|
+ : firstUninstalledIntegrationIndex;
|
|
|
+
|
|
|
+ const activeIntegration =
|
|
|
+ subStep === 'integration' ? selectedIntegrations[activeIntegrationIndex] : null;
|
|
|
+ const activeProvider = activeIntegration
|
|
|
+ ? providers.find(p => p.key === activeIntegration)
|
|
|
+ : null;
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (clientState && !project && projects.length > 0) {
|
|
|
- // Can't find a project to show, probably because all projects are either deleted or finished.
|
|
|
+ // should not redirect if we don't have an active client state or projects aren't loaded
|
|
|
+ if (!clientState || loadingProjects) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ // no integrations left on integration step
|
|
|
+ (subStep === 'integration' && !activeIntegration) ||
|
|
|
+ // If no projects remaining and no integrations to install, then we can leave
|
|
|
+ (subStep === 'project' && !project && !integrationsNotInstalled.length)
|
|
|
+ ) {
|
|
|
browserHistory.push('/');
|
|
|
}
|
|
|
- }, [clientState, project, projects]);
|
|
|
+ });
|
|
|
|
|
|
const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
|
|
|
|
|
@@ -117,6 +174,7 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
|
|
|
const setNewProject = (newProjectId: string) => {
|
|
|
const searchParams = new URLSearchParams({
|
|
|
+ sub_step: 'project',
|
|
|
project_id: newProjectId,
|
|
|
});
|
|
|
browserHistory.push(`${window.location.pathname}?${searchParams}`);
|
|
@@ -138,6 +196,29 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
setNewProject(newProjectId);
|
|
|
};
|
|
|
|
|
|
+ const setNewActiveIntegration = (integration: string) => {
|
|
|
+ const searchParams = new URLSearchParams({
|
|
|
+ integration,
|
|
|
+ sub_step: 'integration',
|
|
|
+ });
|
|
|
+ browserHistory.push(`${window.location.pathname}?${searchParams}`);
|
|
|
+ if (clientState) {
|
|
|
+ setClientState({
|
|
|
+ ...clientState,
|
|
|
+ state: 'integrations_selected',
|
|
|
+ url: `setup-docs/?${searchParams}`,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const selectActiveIntegration = (integration: string) => {
|
|
|
+ trackAdvancedAnalyticsEvent('growth.onboarding_clicked_integration_in_sidebar', {
|
|
|
+ organization,
|
|
|
+ integration,
|
|
|
+ });
|
|
|
+ setNewActiveIntegration(integration);
|
|
|
+ };
|
|
|
+
|
|
|
const missingExampleWarning = () => {
|
|
|
const missingExample =
|
|
|
platformDocs && platformDocs.html.includes(INCOMPLETE_DOC_FLAG);
|
|
@@ -171,7 +252,7 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
|
|
|
const loadingError = (
|
|
|
<LoadingError
|
|
|
- message={t('Failed to load documentation for the %s platform.', project.platform)}
|
|
|
+ message={t('Failed to load documentation for the %s platform.', project?.platform)}
|
|
|
onRetry={fetchData}
|
|
|
/>
|
|
|
);
|
|
@@ -185,30 +266,54 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
return (
|
|
|
<Fragment>
|
|
|
<Wrapper>
|
|
|
- <TargetedOnboardingSidebar
|
|
|
- projects={projects}
|
|
|
- selectedPlatformToProjectIdMap={
|
|
|
- clientState
|
|
|
- ? Object.fromEntries(
|
|
|
- clientState.selectedPlatforms.map(platform => [
|
|
|
- platform,
|
|
|
- clientState.platformToProjectIdMap[platform],
|
|
|
- ])
|
|
|
- )
|
|
|
- : {}
|
|
|
- }
|
|
|
- activeProject={project}
|
|
|
- {...{checkProjectHasFirstEvent, selectProject}}
|
|
|
- />
|
|
|
- <MainContent>
|
|
|
- <FullIntroduction
|
|
|
- currentPlatform={currentPlatform}
|
|
|
- organization={organization}
|
|
|
+ <SidebarWrapper>
|
|
|
+ <ProjectSidebarSection
|
|
|
+ projects={projects}
|
|
|
+ selectedPlatformToProjectIdMap={
|
|
|
+ clientState
|
|
|
+ ? Object.fromEntries(
|
|
|
+ clientState.selectedPlatforms.map(platform => [
|
|
|
+ platform,
|
|
|
+ clientState.platformToProjectIdMap[platform],
|
|
|
+ ])
|
|
|
+ )
|
|
|
+ : {}
|
|
|
+ }
|
|
|
+ activeProject={project}
|
|
|
+ {...{checkProjectHasFirstEvent, selectProject}}
|
|
|
/>
|
|
|
- {getDynamicText({
|
|
|
- value: !hasError ? docs : loadingError,
|
|
|
- fixed: testOnlyAlert,
|
|
|
- })}
|
|
|
+ {selectedIntegrations.length ? (
|
|
|
+ <IntegrationSidebarSection
|
|
|
+ {...{
|
|
|
+ installedIntegrations,
|
|
|
+ selectedIntegrations,
|
|
|
+ activeIntegration,
|
|
|
+ selectActiveIntegration,
|
|
|
+ providers,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ) : null}
|
|
|
+ </SidebarWrapper>
|
|
|
+ <MainContent>
|
|
|
+ {subStep === 'project' ? (
|
|
|
+ <Fragment>
|
|
|
+ <FullIntroduction
|
|
|
+ currentPlatform={currentPlatform}
|
|
|
+ organization={organization}
|
|
|
+ />
|
|
|
+ {getDynamicText({
|
|
|
+ value: !hasError ? docs : loadingError,
|
|
|
+ fixed: testOnlyAlert,
|
|
|
+ })}
|
|
|
+ </Fragment>
|
|
|
+ ) : null}
|
|
|
+ {activeProvider && (
|
|
|
+ <IntegrationInstaller
|
|
|
+ provider={activeProvider}
|
|
|
+ isInstalled={installedIntegrations.has(activeProvider.slug)}
|
|
|
+ setIntegrationInstalled={() => setIntegrationInstalled(activeProvider.slug)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</MainContent>
|
|
|
</Wrapper>
|
|
|
|
|
@@ -221,7 +326,8 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
project.slug ===
|
|
|
clientState.platformToProjectIdMap[
|
|
|
clientState.selectedPlatforms[clientState.selectedPlatforms.length - 1]
|
|
|
- ]
|
|
|
+ ] &&
|
|
|
+ !integrationsNotInstalled.length
|
|
|
}
|
|
|
hasFirstEvent={checkProjectHasFirstEvent(project)}
|
|
|
onClickSetupLater={() => {
|
|
@@ -243,17 +349,20 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
const nextProjectSlug =
|
|
|
nextPlatform && clientState.platformToProjectIdMap[nextPlatform];
|
|
|
const nextProject = projects.find(p => p.slug === nextProjectSlug);
|
|
|
- if (!nextProject) {
|
|
|
- // We're done here.
|
|
|
+ // if we have a next project, switch to that
|
|
|
+ if (nextProject) {
|
|
|
+ setNewProject(nextProject.id);
|
|
|
+ // if we have integrations to install switch to that
|
|
|
+ } else if (integrationsNotInstalled.length) {
|
|
|
+ setNewActiveIntegration(integrationsNotInstalled[0]);
|
|
|
+ // otherwise no integrations and no projects so we're done
|
|
|
+ } else {
|
|
|
setClientState({
|
|
|
...clientState,
|
|
|
state: 'finished',
|
|
|
});
|
|
|
- // TODO: integrations
|
|
|
browserHistory.push(orgIssuesURL);
|
|
|
- return;
|
|
|
}
|
|
|
- setNewProject(nextProject.id);
|
|
|
}}
|
|
|
handleFirstIssueReceived={() => {
|
|
|
const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
|
|
@@ -261,11 +370,80 @@ function SetupDocs({organization, projects, search}: Props) {
|
|
|
}}
|
|
|
/>
|
|
|
)}
|
|
|
+ {activeIntegration && (
|
|
|
+ <SetupIntegrationsFooter
|
|
|
+ organization={organization}
|
|
|
+ onClickSetupLater={() => {
|
|
|
+ trackAdvancedAnalyticsEvent(
|
|
|
+ 'growth.onboarding_clicked_setup_integration_later',
|
|
|
+ {
|
|
|
+ organization,
|
|
|
+ integration: activeIntegration,
|
|
|
+ integration_index: activeIntegrationIndex,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ // find the next uninstalled integration
|
|
|
+ const nextIntegration = selectedIntegrations.find(
|
|
|
+ (i, index) =>
|
|
|
+ !installedIntegrations.has(i) && index > activeIntegrationIndex
|
|
|
+ );
|
|
|
+ // check if we have an integration to set up next
|
|
|
+ if (nextIntegration) {
|
|
|
+ setNewActiveIntegration(nextIntegration);
|
|
|
+ } else {
|
|
|
+ // client state should always exist
|
|
|
+ clientState &&
|
|
|
+ setClientState({
|
|
|
+ ...clientState,
|
|
|
+ state: 'finished',
|
|
|
+ });
|
|
|
+ const nextUrl = `/organizations/${organization.slug}/issues/`;
|
|
|
+ browserHistory.push(nextUrl);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</Fragment>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-export default withProjects(SetupDocs);
|
|
|
+type WrapperState = {
|
|
|
+ configurations: Integration[] | null;
|
|
|
+ information: {providers: IntegrationProvider[]} | null;
|
|
|
+} & AsyncComponent['state'];
|
|
|
+
|
|
|
+type WrapperProps = {
|
|
|
+ projects: Project[];
|
|
|
+ search: string;
|
|
|
+} & StepProps &
|
|
|
+ AsyncComponent['props'];
|
|
|
+
|
|
|
+class SetupDocsWrapper extends AsyncComponent<WrapperProps, WrapperState> {
|
|
|
+ getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
|
|
|
+ const {slug, experiments} = this.props.organization;
|
|
|
+ // would be better to check the onboarding state for integrations instead of the experiment
|
|
|
+ // but we can't access a hook from a class component
|
|
|
+ if (!experiments.TargetedOnboardingIntegrationSelectExperiment) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ ['information', `/organizations/${slug}/config/integrations/`],
|
|
|
+ ['configurations', `/organizations/${slug}/integrations/?includeConfig=0`],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ renderBody() {
|
|
|
+ const {configurations, information} = this.state;
|
|
|
+ return (
|
|
|
+ <SetupDocs
|
|
|
+ {...this.props}
|
|
|
+ configurations={configurations ?? []}
|
|
|
+ providers={information?.providers || []}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default withProjects(SetupDocsWrapper);
|
|
|
|
|
|
type AlertType = React.ComponentProps<typeof Alert>['type'];
|
|
|
|
|
@@ -345,3 +523,17 @@ const MainContent = styled('div')`
|
|
|
min-width: 0;
|
|
|
flex-grow: 1;
|
|
|
`;
|
|
|
+
|
|
|
+// the number icon will be space(2) + 30px to the left of the margin of center column
|
|
|
+// so we need to offset the right margin by that much
|
|
|
+// also hide the sidebar if the screen is too small
|
|
|
+const SidebarWrapper = styled('div')`
|
|
|
+ margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
|
|
|
+ @media (max-width: 1150px) {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+ flex-basis: 240px;
|
|
|
+ flex-grow: 0;
|
|
|
+ flex-shrink: 0;
|
|
|
+ min-width: 240px;
|
|
|
+`;
|