]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11327 Redirect user after organization creation depending on context
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 25 Oct 2018 13:37:19 +0000 (15:37 +0200)
committerSonarTech <sonartech@sonarsource.com>
Fri, 16 Nov 2018 19:21:05 +0000 (20:21 +0100)
* Correctly handle OnboardingModal for create organization page

12 files changed:
server/sonar-web/src/main/js/app/components/StartupModal.tsx
server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/utils.ts
server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/OrganizationInput.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap
server/sonar-web/src/main/js/components/ui/IdentityProviderLink.tsx

index 76d0083725f4b382aedf25856e0ef5b85950a99e..528d66ec5f7de2706ac63108603ed3e1c1e56616 100644 (file)
@@ -25,12 +25,11 @@ import { CurrentUser, Organization } from '../types';
 import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates';
 import { EditionKey } from '../../apps/marketplace/utils';
 import { getCurrentUser, getAppState, Store } from '../../store/rootReducer';
-import { skipOnboarding as skipOnboardingAction } from '../../store/users';
+import { skipOnboarding } from '../../store/users';
 import { showLicense } from '../../api/marketplace';
 import { hasMessage } from '../../helpers/l10n';
 import { save, get } from '../../helpers/storage';
 import { isSonarCloud } from '../../helpers/system';
-import { skipOnboarding } from '../../api/users';
 import { lazyLoad } from '../../components/lazyLoad';
 import { isLoggedIn } from '../../helpers/users';
 
@@ -54,7 +53,7 @@ interface StateProps {
 }
 
 interface DispatchProps {
-  skipOnboardingAction: () => void;
+  skipOnboarding: () => void;
 }
 
 interface OwnProps {
@@ -95,8 +94,7 @@ export class StartupModal extends React.PureComponent<Props, State> {
   closeOnboarding = () => {
     this.setState(state => {
       if (state.modal !== ModalKey.license) {
-        skipOnboarding();
-        this.props.skipOnboardingAction();
+        this.props.skipOnboarding();
         return { automatic: false, modal: undefined };
       }
       return null;
@@ -165,8 +163,8 @@ export class StartupModal extends React.PureComponent<Props, State> {
     const { currentUser, location } = this.props;
     if (
       currentUser.showOnboardingTutorial &&
-      !['about', 'documentation', 'onboarding', 'projects/create'].some(path =>
-        location.pathname.startsWith(path)
+      !['about', 'documentation', 'onboarding', 'projects/create', 'create-organization'].some(
+        path => location.pathname.startsWith(path)
       )
     ) {
       this.setState({ automatic: true });
@@ -209,7 +207,7 @@ const mapStateToProps = (state: Store): StateProps => ({
   currentUser: getCurrentUser(state)
 });
 
-const mapDispatchToProps: DispatchProps = { skipOnboardingAction };
+const mapDispatchToProps: DispatchProps = { skipOnboarding };
 
 export default connect(
   mapStateToProps,
index 62feff5e4583a845ee3e7cce9622262cf7c17a6d..e04e23bbdac5e53ed1fca54c84cd12b336cb1fd1 100644 (file)
@@ -145,7 +145,7 @@ function getWrapper(props = {}) {
       currentUser={LOGGED_IN_USER}
       location={{ pathname: 'foo/bar' } as Location}
       router={mockRouter() as InjectedRouter}
-      skipOnboardingAction={jest.fn()}
+      skipOnboarding={jest.fn()}
       {...props}>
       <div />
     </StartupModal>
index 763ebef5e173ba4e7d2efe23ea477abce80330a8..5357076557b8dc5a4768ea626b078069621a6841 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { differenceInMinutes } from 'date-fns';
 import { times } from 'lodash';
 import { connect } from 'react-redux';
 import { Dispatch } from 'redux';
 import { Helmet } from 'react-helmet';
 import { FormattedMessage } from 'react-intl';
 import { Link, withRouter, WithRouterProps } from 'react-router';
-import { formatPrice, parseQuery } from './utils';
+import {
+  formatPrice,
+  parseQuery,
+  ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP
+} from './utils';
 import AlmApplicationInstalling from './AlmApplicationInstalling';
 import AutoOrganizationCreate from './AutoOrganizationCreate';
 import AutoPersonalOrganizationBind from './AutoPersonalOrganizationBind';
@@ -51,8 +56,11 @@ import {
 } from '../../../app/types';
 import { hasAdvancedALMIntegration, isPersonal } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
+import { get, remove } from '../../../helpers/storage';
 import { slugify } from '../../../helpers/strings';
 import { getOrganizationUrl } from '../../../helpers/urls';
+import { skipOnboarding as skipOnboardingAction } from '../../../store/users';
+import { skipOnboarding } from '../../../api/users';
 import * as api from '../../../api/organizations';
 import * as actions from '../../../store/organizations';
 import '../../../app/styles/sonarcloud.css';
@@ -68,6 +76,7 @@ interface Props {
     organization: OrganizationBase & { installationId?: string }
   ) => Promise<Organization>;
   userOrganizations: Organization[];
+  skipOnboardingAction: () => void;
 }
 
 interface State {
@@ -187,10 +196,24 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   };
 
   handleOrgCreated = (organization: string, justCreated = true) => {
-    this.props.router.push({
-      pathname: getOrganizationUrl(organization),
-      state: { justCreated }
-    });
+    skipOnboarding().catch(() => {});
+    this.props.skipOnboardingAction();
+    const redirectProjectTimestamp = get(ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP);
+    remove(ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP);
+    if (
+      redirectProjectTimestamp &&
+      differenceInMinutes(Date.now(), Number(redirectProjectTimestamp)) < 10
+    ) {
+      this.props.router.push({
+        pathname: '/projects/create',
+        state: { organization, tab: this.state.almOrganization ? 'auto' : 'manual' }
+      });
+    } else {
+      this.props.router.push({
+        pathname: getOrganizationUrl(organization),
+        state: { justCreated }
+      });
+    }
   };
 
   onTabChange = (tab: TabKeys) => {
@@ -367,7 +390,8 @@ function deleteOrganization(key: string) {
 const mapDispatchToProps = {
   createOrganization: createOrganization as any,
   deleteOrganization: deleteOrganization as any,
-  updateOrganization: updateOrganization as any
+  updateOrganization: updateOrganization as any,
+  skipOnboardingAction: skipOnboardingAction as any
 };
 
 export default whenLoggedIn(
index b73259b90767e77718735ec6cad718f658c52de1..09d55dcf6b1f96aaae9211641243195b1716facf 100644 (file)
@@ -31,6 +31,7 @@ import {
 } from '../../../../api/alm-integration';
 import { getSubscriptionPlans } from '../../../../api/billing';
 import { getOrganizations } from '../../../../api/organizations';
+import { get, remove } from '../../../../helpers/storage';
 
 jest.mock('../../../../api/billing', () => ({
   getSubscriptionPlans: jest
@@ -63,6 +64,11 @@ jest.mock('../../../../api/organizations', () => ({
   getOrganizations: jest.fn().mockResolvedValue({ organizations: [] })
 }));
 
+jest.mock('../../../../helpers/storage', () => ({
+  get: jest.fn().mockReturnValue(undefined),
+  remove: jest.fn()
+}));
+
 const user: LoggedInUser = {
   groups: [],
   isLoggedIn: true,
@@ -78,6 +84,8 @@ beforeEach(() => {
   (listUnboundApplications as jest.Mock<any>).mockClear();
   (getSubscriptionPlans as jest.Mock<any>).mockClear();
   (getOrganizations as jest.Mock<any>).mockClear();
+  (get as jest.Mock<any>).mockClear();
+  (remove as jest.Mock<any>).mockClear();
 });
 
 it('should render with manual tab displayed', async () => {
@@ -187,14 +195,59 @@ it('should reload the alm organization when the url query changes', async () =>
   expect(listUnboundApplications).toHaveBeenCalledTimes(2);
 });
 
+it('should redirect to organization page after creation', async () => {
+  const push = jest.fn();
+  const wrapper = shallowRender({ router: mockRouter({ push }) });
+  await waitAndUpdate(wrapper);
+
+  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  expect(push).toHaveBeenCalledWith({
+    pathname: '/organizations/foo',
+    state: { justCreated: true }
+  });
+
+  (get as jest.Mock<any>).mockReturnValueOnce('0');
+  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo', false);
+  expect(push).toHaveBeenCalledWith({
+    pathname: '/organizations/foo',
+    state: { justCreated: false }
+  });
+});
+
+it('should redirect to projects creation page after creation', async () => {
+  const push = jest.fn();
+  const wrapper = shallowRender({ router: mockRouter({ push }) });
+  await waitAndUpdate(wrapper);
+
+  (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
+  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  expect(get).toHaveBeenCalled();
+  expect(remove).toHaveBeenCalled();
+  expect(push).toHaveBeenCalledWith({
+    pathname: '/projects/create',
+    state: { organization: 'foo', tab: 'manual' }
+  });
+
+  wrapper.setState({ almOrganization: { key: 'foo', name: 'Foo', avatar: 'my-avatar' } });
+  (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
+  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  expect(push).toHaveBeenCalledWith({
+    pathname: '/projects/create',
+    state: { organization: 'foo', tab: 'auto' }
+  });
+});
+
 function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
   return shallow(
     <CreateOrganization
+      createOrganization={jest.fn()}
       currentUser={user}
-      {...props}
+      deleteOrganization={jest.fn()}
       // @ts-ignore avoid passing everything from WithRouterProps
       location={{}}
       router={mockRouter()}
+      skipOnboardingAction={jest.fn()}
+      updateOrganization={jest.fn()}
       userOrganizations={[
         { key: 'foo', name: 'Foo' },
         { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' }
index 741ed46784e196cf0f3578e0f0420b9882e649b4..e7b7edca442fc9e794898b8a351a06d3a994ddb6 100644 (file)
@@ -74,6 +74,7 @@ exports[`should render with auto personal organization bind page 2`] = `
         }
       }
       onOrgCreated={[Function]}
+      updateOrganization={[MockFunction]}
     />
   </div>
 </Fragment>
@@ -149,6 +150,7 @@ exports[`should render with auto tab displayed 1`] = `
         }
       }
       almUnboundApplications={Array []}
+      createOrganization={[MockFunction]}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
@@ -250,6 +252,7 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
         }
       }
       almUnboundApplications={Array []}
+      createOrganization={[MockFunction]}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
@@ -307,6 +310,8 @@ exports[`should render with manual tab displayed 1`] = `
       </p>
     </header>
     <ManualOrganizationCreate
+      createOrganization={[MockFunction]}
+      deleteOrganization={[MockFunction]}
       onOrgCreated={[Function]}
       subscriptionPlans={
         Array [
@@ -395,6 +400,7 @@ exports[`should switch tabs 1`] = `
         }
       }
       almUnboundApplications={Array []}
+      createOrganization={[MockFunction]}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
index e5c92d2816480017e768bdc9bbe8324d022fe6d2..1020a9288fa71862b84da913d37d82f26ed1dbf3 100644 (file)
@@ -28,6 +28,9 @@ import {
 } from '../../../helpers/query';
 import { isBitbucket, isGithub } from '../../../helpers/almIntegrations';
 
+export const ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP =
+  'sonarcloud.import_org.redirect_to_projects';
+
 export function formatPrice(price?: number, noSign?: boolean) {
   const priceFormatted = formatMeasure(price, 'FLOAT')
     .replace(/[.|,]0$/, '')
index ad6f002914ff3ad7ef7b5ac9fff86afb26b967bb..58795c101bf87c44cfe8dd426a4856fc46fd8f79 100644 (file)
@@ -22,7 +22,9 @@ import RemoteRepositories from './RemoteRepositories';
 import OrganizationInput from './OrganizationInput';
 import IdentityProviderLink from '../../../components/ui/IdentityProviderLink';
 import { AlmApplication, Organization } from '../../../app/types';
+import { ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP } from '../organization/utils';
 import { translate } from '../../../helpers/l10n';
+import { save } from '../../../helpers/storage';
 
 interface Props {
   almApplication: AlmApplication;
@@ -53,6 +55,10 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State>
     return '';
   }
 
+  handleInstallAppClick = () => {
+    save(ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP, Date.now().toString(10));
+  };
+
   handleOrganizationSelect = ({ key }: Organization) => {
     this.setState({ selectedOrganization: key });
   };
@@ -66,6 +72,7 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State>
           <IdentityProviderLink
             className="display-inline-block"
             identityProvider={almApplication}
+            onClick={this.handleInstallAppClick}
             small={true}
             url={almApplication.installationUrl}>
             {translate(
index e7fea91bbaceb18c49a400e9959e0b83f0cb2fa7..81f5d8930f23bd64c6cb6465831bf6f6b684d8ba 100644 (file)
 import * as React from 'react';
 import { WithRouterProps, withRouter } from 'react-router';
 import OrganizationSelect from '../components/OrganizationSelect';
+import { ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP } from '../organization/utils';
 import { Organization } from '../../../app/types';
 import { translate } from '../../../helpers/l10n';
+import { save } from '../../../helpers/storage';
 
 interface Props {
   autoImport?: boolean;
@@ -34,6 +36,7 @@ export class OrganizationInput extends React.PureComponent<Props & WithRouterPro
   handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
     event.preventDefault();
     event.stopPropagation();
+    save(ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP, Date.now().toString(10));
     this.props.router.push({
       pathname: '/create-organization',
       state: { tab: this.props.autoImport ? 'auto' : 'manual' }
index cd98e6da4c2b96c14eb94056df35cfbe2008771d..4e804d8e2d2d692081259680d9c95385619a1b8d 100644 (file)
@@ -56,6 +56,7 @@ exports[`should display the provider app install button 1`] = `
         "name": "GitHub",
       }
     }
+    onClick={[Function]}
     small={true}
     url="https://alm.installation.url"
   >
index 60a134428aaff079557075e873b4a7b544ab6bab..d7a8aa94acda8f4cc4d15263b2f178bc66c92a81 100644 (file)
@@ -51,6 +51,10 @@ export class OnboardingModal extends React.PureComponent<Props> {
     }
   }
 
+  handleOpenProjectOnboarding = () => {
+    this.props.onOpenProjectOnboarding();
+  };
+
   render() {
     if (!isLoggedIn(this.props.currentUser)) {
       return null;
@@ -68,7 +72,7 @@ export class OnboardingModal extends React.PureComponent<Props> {
           <p className="spacer-top">{translate('onboarding.header.description')}</p>
         </div>
         <div className="modal-simple-body text-center onboarding-choices">
-          <Button className="onboarding-choice" onClick={this.props.onOpenProjectOnboarding}>
+          <Button className="onboarding-choice" onClick={this.handleOpenProjectOnboarding}>
             <OnboardingProjectIcon className="big-spacer-bottom" />
             <h6 className="onboarding-choice-name">
               {translate('onboarding.analyze_public_code')}
index 1b46fdaca6671ba4e1ae6f9085af7970e548e344..5888278c63db2de3116bc4c55d889d96fb03b04c 100644 (file)
@@ -24,7 +24,7 @@ exports[`renders correctly 1`] = `
   >
     <Button
       className="onboarding-choice"
-      onClick={[MockFunction]}
+      onClick={[Function]}
     >
       <OnboardingProjectIcon
         className="big-spacer-bottom"
index 9e4f87c5ed33f9e7b3fc3ba709f82661c484e5b5..75354424c63af04fa14702632394ddc62ee88982 100644 (file)
@@ -28,6 +28,7 @@ interface Props {
   children: React.ReactNode;
   className?: string;
   identityProvider: IdentityProvider;
+  onClick?: () => void;
   small?: boolean;
   url: string | undefined;
 }
@@ -36,6 +37,7 @@ export default function IdentityProviderLink({
   children,
   className,
   identityProvider,
+  onClick,
   small,
   url
 }: Props) {
@@ -49,6 +51,7 @@ export default function IdentityProviderLink({
         className
       )}
       href={url}
+      onClick={onClick}
       style={{ backgroundColor: identityProvider.backgroundColor }}>
       <img
         alt={identityProvider.name}