]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-175 Support step to upgrade organization when importing from ALM
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 22 Nov 2018 13:30:49 +0000 (14:30 +0100)
committerSonarTech <sonartech@sonarsource.com>
Fri, 7 Dec 2018 19:21:04 +0000 (20:21 +0100)
21 files changed:
server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/utils.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 06fa37b4e9d153706a1c7e9e7db6ce7072280475..85627bed7caffcaf537065b5aa05c4704236f00c 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as classNames from 'classnames';
 import { FormattedMessage } from 'react-intl';
 import AutoOrganizationBind from './AutoOrganizationBind';
-import RemoteOrganizationChoose from './RemoteOrganizationChoose';
 import OrganizationDetailsForm from './OrganizationDetailsForm';
-import { Query } from './utils';
-import RadioToggle from '../../../components/controls/RadioToggle';
+import OrganizationDetailsStep from './OrganizationDetailsStep';
+import PlanStep from './PlanStep';
+import { Step } from './utils';
 import { DeleteButton } from '../../../components/ui/buttons';
+import RadioToggle from '../../../components/controls/RadioToggle';
 import { bindAlmOrganization } from '../../../api/alm-integration';
 import { sanitizeAlmId } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/urls';
 
-export enum Filters {
+enum Filters {
   Bind = 'bind',
   Create = 'create'
 }
 
 interface Props {
   almApplication: T.AlmApplication;
-  almInstallId?: string;
-  almOrganization?: T.AlmOrganization;
-  almUnboundApplications: T.AlmUnboundApplication[];
-  boundOrganization?: T.OrganizationBase;
+  almInstallId: string;
+  almOrganization: T.AlmOrganization;
   className?: string;
   createOrganization: (
-    organization: T.OrganizationBase & { installationId?: string }
-  ) => Promise<T.Organization>;
+    organization: T.Organization & { installationId?: string }
+  ) => Promise<string>;
+  handleCancelImport: () => void;
+  handleOrgDetailsFinish: (organization: T.Organization) => Promise<void>;
+  handleOrgDetailsStepOpen: () => void;
+  onDone: () => void;
   onOrgCreated: (organization: string, justCreated?: boolean) => void;
+  onUpgradeFail: () => void;
+  organization?: T.Organization;
+  step: Step;
+  subscriptionPlans?: T.SubscriptionPlan[];
   unboundOrganizations: T.Organization[];
-  updateUrlQuery: (query: Partial<Query>) => void;
 }
 
 interface State {
@@ -64,121 +69,117 @@ export default class AutoOrganizationCreate extends React.PureComponent<Props, S
   }
 
   handleBindOrganization = (organization: string) => {
-    if (this.props.almInstallId) {
-      return bindAlmOrganization({
-        organization,
-        installationId: this.props.almInstallId
-      }).then(() => this.props.onOrgCreated(organization, false));
-    }
-    return Promise.reject();
+    return bindAlmOrganization({
+      organization,
+      installationId: this.props.almInstallId
+    }).then(() => this.props.onOrgCreated(organization, false));
   };
 
-  handleCancelImport = () => {
-    this.props.updateUrlQuery({ almInstallId: undefined, almKey: undefined });
-  };
-
-  handleCreateOrganization = (organization: Required<T.OrganizationBase>) => {
-    return this.props
-      .createOrganization({
-        avatar: organization.avatar,
-        description: organization.description,
-        installationId: this.props.almInstallId,
-        key: organization.key,
-        name: organization.name || organization.key,
-        url: organization.url
-      })
-      .then(({ key }) => this.props.onOrgCreated(key));
+  handleCreateOrganization = () => {
+    const { organization } = this.props;
+    if (!organization) {
+      return Promise.reject();
+    }
+    return this.props.createOrganization({
+      ...organization,
+      installationId: this.props.almInstallId
+    });
   };
 
   handleOptionChange = (filter: Filters) => {
     this.setState({ filter });
   };
 
-  renderContent = (almOrganization: T.AlmOrganization) => {
-    const { almApplication, unboundOrganizations } = this.props;
-
+  render() {
+    const {
+      almApplication,
+      almOrganization,
+      className,
+      organization,
+      step,
+      subscriptionPlans,
+      unboundOrganizations
+    } = this.props;
     const { filter } = this.state;
     const hasUnboundOrgs = unboundOrganizations.length > 0;
     return (
-      <div className="boxed-group-inner">
-        <div className="huge-spacer-bottom">
-          <p className="display-flex-center big-spacer-bottom">
-            <FormattedMessage
-              defaultMessage={translate('onboarding.import_organization_x')}
-              id="onboarding.import_organization_x"
-              values={{
-                avatar: (
-                  <img
-                    alt={almApplication.name}
-                    className="little-spacer-left"
-                    src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(
-                      almApplication.key
-                    )}.svg`}
-                    width={16}
-                  />
-                ),
-                name: <strong>{almOrganization.name}</strong>
-              }}
-            />
-            <DeleteButton className="little-spacer-left" onClick={this.handleCancelImport} />
-          </p>
+      <div className={className}>
+        <OrganizationDetailsStep
+          finished={organization !== undefined}
+          onOpen={this.props.handleOrgDetailsStepOpen}
+          open={step === Step.OrganizationDetails}
+          organization={organization}
+          stepTitle={translate('onboarding.import_organization.import_org_details')}>
+          <div className="huge-spacer-bottom">
+            <p className="display-flex-center big-spacer-bottom">
+              <FormattedMessage
+                defaultMessage={translate('onboarding.import_organization_x')}
+                id="onboarding.import_organization_x"
+                values={{
+                  avatar: (
+                    <img
+                      alt={almApplication.name}
+                      className="little-spacer-left"
+                      src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(
+                        almApplication.key
+                      )}.svg`}
+                      width={16}
+                    />
+                  ),
+                  name: <strong>{almOrganization.name}</strong>
+                }}
+              />
+              <DeleteButton
+                className="little-spacer-left"
+                onClick={this.props.handleCancelImport}
+              />
+            </p>
 
-          {hasUnboundOrgs && (
-            <RadioToggle
-              name="filter"
-              onCheck={this.handleOptionChange}
-              options={[
-                {
-                  label: translate('onboarding.import_organization.create_new'),
-                  value: Filters.Create
-                },
-                {
-                  label: translate('onboarding.import_organization.bind_existing'),
-                  value: Filters.Bind
-                }
-              ]}
-              value={filter}
+            {hasUnboundOrgs && (
+              <RadioToggle
+                name="filter"
+                onCheck={this.handleOptionChange}
+                options={[
+                  {
+                    label: translate('onboarding.import_organization.create_new'),
+                    value: Filters.Create
+                  },
+                  {
+                    label: translate('onboarding.import_organization.bind_existing'),
+                    value: Filters.Bind
+                  }
+                ]}
+                value={filter}
+              />
+            )}
+          </div>
+
+          {filter === Filters.Create && (
+            <OrganizationDetailsForm
+              onContinue={this.props.handleOrgDetailsFinish}
+              organization={almOrganization}
+              submitText={translate('continue')}
             />
           )}
-        </div>
-
-        {filter === Filters.Create && (
-          <OrganizationDetailsForm
-            onContinue={this.handleCreateOrganization}
-            organization={almOrganization}
-            submitText={translate('onboarding.import_organization.import')}
-          />
-        )}
-        {filter === Filters.Bind && (
-          <AutoOrganizationBind
-            onBindOrganization={this.handleBindOrganization}
-            unboundOrganizations={unboundOrganizations}
-          />
-        )}
-      </div>
-    );
-  };
-
-  render() {
-    const { almInstallId, almOrganization, boundOrganization, className } = this.props;
-
-    return (
-      <div className={classNames('boxed-group', className)}>
-        <div className="boxed-group-header">
-          <h2>{translate('onboarding.import_organization.import_org_details')}</h2>
-        </div>
+          {filter === Filters.Bind && (
+            <AutoOrganizationBind
+              onBindOrganization={this.handleBindOrganization}
+              unboundOrganizations={unboundOrganizations}
+            />
+          )}
+        </OrganizationDetailsStep>
 
-        {almInstallId && almOrganization && !boundOrganization ? (
-          this.renderContent(almOrganization)
-        ) : (
-          <RemoteOrganizationChoose
-            almApplication={this.props.almApplication}
-            almInstallId={almInstallId}
-            almOrganization={almOrganization}
-            almUnboundApplications={this.props.almUnboundApplications}
-            boundOrganization={boundOrganization}
-          />
-        )}
+        {subscriptionPlans !== undefined &&
+          filter !== Filters.Bind && (
+            <PlanStep
+              createOrganization={this.handleCreateOrganization}
+              onDone={this.props.onDone}
+              onUpgradeFail={this.props.onUpgradeFail}
+              onlyPaid={false /* TODO */}
+              open={step === Step.Plan}
+              subscriptionPlans={subscriptionPlans}
+            />
+          )}
       </div>
     );
   }
index 875e0b8555b2f9a874b23cc8d11a04cdc667cf1d..fb903669336026a8472db6c9fee16866c5d6cafa 100644 (file)
@@ -20,7 +20,9 @@
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import OrganizationDetailsForm from './OrganizationDetailsForm';
-import { Query } from './utils';
+import OrganizationDetailsStep from './OrganizationDetailsStep';
+import PlanStep from './PlanStep';
+import { Step } from './utils';
 import { DeleteButton } from '../../../components/ui/buttons';
 import { getBaseUrl } from '../../../helpers/urls';
 import { translate } from '../../../helpers/l10n';
@@ -31,37 +33,48 @@ interface Props {
   almApplication: T.AlmApplication;
   almInstallId?: string;
   almOrganization: T.AlmOrganization;
+  handleCancelImport: () => void;
+  handleOrgDetailsFinish: (organization: T.Organization) => Promise<void>;
+  handleOrgDetailsStepOpen: () => void;
   importPersonalOrg: T.Organization;
-  onOrgCreated: (organization: string) => void;
+  onDone: () => void;
+  organization?: T.Organization;
+  step: Step;
+  subscriptionPlans?: T.SubscriptionPlan[];
   updateOrganization: (
-    organization: T.OrganizationBase & { installationId?: string }
-  ) => Promise<T.Organization>;
-  updateUrlQuery: (query: Partial<Query>) => void;
+    organization: T.Organization & { installationId?: string }
+  ) => Promise<string>;
 }
 
 export default class AutoPersonalOrganizationBind extends React.PureComponent<Props> {
-  handleCancelImport = () => {
-    this.props.updateUrlQuery({ almInstallId: undefined, almKey: undefined });
+  handleCreateOrganization = () => {
+    const { organization } = this.props;
+    if (!organization) {
+      return Promise.reject();
+    }
+    return this.props.updateOrganization({
+      ...organization,
+      installationId: this.props.almInstallId
+    });
   };
 
-  handleCreateOrganization = (organization: Required<T.OrganizationBase>) => {
-    return this.props
-      .updateOrganization({
-        avatar: organization.avatar,
-        description: organization.description,
-        installationId: this.props.almInstallId,
-        key: this.props.importPersonalOrg.key,
-        name: organization.name || organization.key,
-        url: organization.url
-      })
-      .then(({ key }) => this.props.onOrgCreated(key));
+  handleOrgDetailsFinish = (organization: T.Organization) => {
+    return this.props.handleOrgDetailsFinish({
+      ...organization,
+      key: this.props.importPersonalOrg.key
+    });
   };
 
   render() {
-    const { almApplication, importPersonalOrg } = this.props;
+    const { almApplication, importPersonalOrg, organization, step, subscriptionPlans } = this.props;
     return (
-      <div className="boxed-group">
-        <div className="boxed-group-inner">
+      <>
+        <OrganizationDetailsStep
+          finished={organization !== undefined}
+          onOpen={this.props.handleOrgDetailsStepOpen}
+          open={step === Step.OrganizationDetails}
+          organization={organization}
+          stepTitle={translate('onboarding.import_organization.personal.import_org_details')}>
           <div className="display-flex-center big-spacer-bottom">
             <FormattedMessage
               defaultMessage={translate('onboarding.import_personal_organization_x')}
@@ -84,16 +97,25 @@ export default class AutoPersonalOrganizationBind extends React.PureComponent<Pr
                 personalName: importPersonalOrg && <strong>{importPersonalOrg.name}</strong>
               }}
             />
-            <DeleteButton className="little-spacer-left" onClick={this.handleCancelImport} />
+            <DeleteButton className="little-spacer-left" onClick={this.props.handleCancelImport} />
           </div>
           <OrganizationDetailsForm
             keyReadOnly={true}
-            onContinue={this.handleCreateOrganization}
+            onContinue={this.handleOrgDetailsFinish}
             organization={importPersonalOrg}
-            submitText={translate('onboarding.import_organization.bind')}
+            submitText={translate('continue')}
           />
-        </div>
-      </div>
+        </OrganizationDetailsStep>
+        {subscriptionPlans !== undefined && (
+          <PlanStep
+            createOrganization={this.handleCreateOrganization}
+            onDone={this.props.onDone}
+            onlyPaid={false /* TODO */}
+            open={step === Step.Plan}
+            subscriptionPlans={subscriptionPlans}
+          />
+        )}
+      </>
     );
   }
 }
index 1de0e054c9b71aa8bc4f42995735837cf9eeb113..df312aa78046ae32e1939d03b378b8f5909a472e 100644 (file)
@@ -32,12 +32,14 @@ import {
   parseQuery,
   serializeQuery,
   Query,
-  ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP
+  ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP,
+  Step
 } from './utils';
 import AlmApplicationInstalling from './AlmApplicationInstalling';
 import AutoOrganizationCreate from './AutoOrganizationCreate';
 import AutoPersonalOrganizationBind from './AutoPersonalOrganizationBind';
 import ManualOrganizationCreate from './ManualOrganizationCreate';
+import RemoteOrganizationChoose from './RemoteOrganizationChoose';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import Tabs from '../../../components/controls/Tabs';
 import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn';
@@ -63,13 +65,13 @@ import '../../tutorials/styles.css'; // TODO remove me
 
 interface Props {
   createOrganization: (
-    organization: T.OrganizationBase & { installationId?: string }
-  ) => Promise<T.Organization>;
+    organization: T.Organization & { installationId?: string }
+  ) => Promise<string>;
   currentUser: T.LoggedInUser;
   deleteOrganization: (key: string) => Promise<void>;
   updateOrganization: (
-    organization: T.OrganizationBase & { installationId?: string }
-  ) => Promise<T.Organization>;
+    organization: T.Organization & { installationId?: string }
+  ) => Promise<string>;
   userOrganizations: T.Organization[];
   skipOnboarding: () => void;
 }
@@ -82,6 +84,7 @@ interface State {
   boundOrganization?: T.OrganizationBase;
   loading: boolean;
   organization?: T.Organization;
+  step: Step;
   subscriptionPlans?: T.SubscriptionPlan[];
 }
 
@@ -96,7 +99,12 @@ interface LocationState {
 
 export class CreateOrganization extends React.PureComponent<Props & WithRouterProps, State> {
   mounted = false;
-  state: State = { almOrgLoading: false, almUnboundApplications: [], loading: true };
+  state: State = {
+    almOrgLoading: false,
+    almUnboundApplications: [],
+    loading: true,
+    step: Step.OrganizationDetails
+  };
 
   componentDidMount() {
     this.mounted = true;
@@ -139,6 +147,12 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     }
   }
 
+  deleteOrganization = () => {
+    if (this.state.organization) {
+      this.props.deleteOrganization(this.state.organization.key);
+    }
+  };
+
   fetchAlmApplication = () => {
     return getAlmAppInfo().then(({ application }) => {
       if (this.mounted) {
@@ -147,35 +161,6 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     });
   };
 
-  fetchAlmUnboundApplications = () => {
-    return listUnboundApplications().then(almUnboundApplications => {
-      if (this.mounted) {
-        this.setState({ almUnboundApplications });
-      }
-    });
-  };
-
-  hasAutoImport(state: State, paid?: boolean): state is StateWithAutoImport {
-    return Boolean(state.almApplication && !paid);
-  }
-
-  setValidOrgKey = (almOrganization: T.AlmOrganization) => {
-    const key = slugify(almOrganization.key);
-    const keys = [key, ...times(9, i => `${key}-${i + 1}`)];
-    return api
-      .getOrganizations({ organizations: keys.join(',') })
-      .then(
-        ({ organizations }) => {
-          const availableKey = keys.find(key => !organizations.find(o => o.key === key));
-          return availableKey || `${key}-${Math.ceil(Math.random() * 1000) + 10}`;
-        },
-        () => key
-      )
-      .then(key => {
-        return { almOrganization: { ...almOrganization, key } };
-      });
-  };
-
   fetchAlmOrganization = (installationId: string) => {
     this.setState({ almOrgLoading: true });
     return getAlmOrganization({ installationId })
@@ -209,6 +194,14 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
       );
   };
 
+  fetchAlmUnboundApplications = () => {
+    return listUnboundApplications().then(almUnboundApplications => {
+      if (this.mounted) {
+        this.setState({ almUnboundApplications });
+      }
+    });
+  };
+
   fetchSubscriptionPlans = () => {
     return getSubscriptionPlans().then(subscriptionPlans => {
       if (this.mounted) {
@@ -217,6 +210,10 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     });
   };
 
+  handleCancelImport = () => {
+    this.updateUrlQuery({ almInstallId: undefined, almKey: undefined });
+  };
+
   handleOrgCreated = (organization: string, justCreated = true) => {
     this.props.skipOnboarding();
     if (this.isStoredTimestampValid(ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP)) {
@@ -232,6 +229,25 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     }
   };
 
+  handleOrgDetailsFinish = (organization: T.Organization) => {
+    this.setState({ organization, step: Step.Plan });
+    return Promise.resolve();
+  };
+
+  handleOrgDetailsStepOpen = () => {
+    this.setState({ step: Step.OrganizationDetails });
+  };
+
+  handlePlanDone = () => {
+    if (this.state.organization) {
+      this.handleOrgCreated(this.state.organization.key);
+    }
+  };
+
+  hasAutoImport(state: State): state is StateWithAutoImport {
+    return Boolean(state.almApplication);
+  }
+
   isStoredTimestampValid = (timestampKey: string) => {
     const storedTimestamp = get(timestampKey);
     remove(timestampKey);
@@ -242,6 +258,23 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     this.updateUrlState({ tab });
   };
 
+  setValidOrgKey = (almOrganization: T.AlmOrganization) => {
+    const key = slugify(almOrganization.key);
+    const keys = [key, ...times(9, i => `${key}-${i + 1}`)];
+    return api
+      .getOrganizations({ organizations: keys.join(',') })
+      .then(
+        ({ organizations }) => {
+          const availableKey = keys.find(key => !organizations.find(o => o.key === key));
+          return availableKey || `${key}-${Math.ceil(Math.random() * 1000) + 10}`;
+        },
+        () => key
+      )
+      .then(key => {
+        return { almOrganization: { ...almOrganization, key } };
+      });
+  };
+
   stopLoading = () => {
     if (this.mounted) {
       this.setState({ loading: false });
@@ -267,66 +300,97 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   renderContent = (almInstallId?: string, importPersonalOrg?: T.Organization) => {
     const { currentUser, location } = this.props;
     const { state } = this;
-    const { almOrganization } = state;
+    const { organization, step, subscriptionPlans } = state;
     const { paid, tab = 'auto' } = (location.state || {}) as LocationState;
 
-    if (importPersonalOrg && almOrganization && state.almApplication) {
+    const commonProps = {
+      handleOrgDetailsFinish: this.handleOrgDetailsFinish,
+      handleOrgDetailsStepOpen: this.handleOrgDetailsStepOpen,
+      onDone: this.handlePlanDone,
+      organization,
+      step,
+      subscriptionPlans
+    };
+
+    if (!this.hasAutoImport(state)) {
+      return (
+        <ManualOrganizationCreate
+          {...commonProps}
+          createOrganization={this.props.createOrganization}
+          onUpgradeFail={this.deleteOrganization}
+          onlyPaid={paid}
+          organization={this.state.organization}
+          step={this.state.step}
+        />
+      );
+    }
+
+    const { almApplication, almOrganization, boundOrganization } = state;
+
+    if (importPersonalOrg && almOrganization && almApplication) {
       return (
         <AutoPersonalOrganizationBind
-          almApplication={state.almApplication}
+          {...commonProps}
+          almApplication={almApplication}
           almInstallId={almInstallId}
           almOrganization={almOrganization}
+          handleCancelImport={this.handleCancelImport}
           importPersonalOrg={importPersonalOrg}
-          onOrgCreated={this.handleOrgCreated}
+          subscriptionPlans={subscriptionPlans}
           updateOrganization={this.props.updateOrganization}
-          updateUrlQuery={this.updateUrlQuery}
         />
       );
     }
 
     return (
       <>
-        {this.hasAutoImport(state, paid) && (
-          <Tabs<TabKeys>
-            onChange={this.onTabChange}
-            selected={tab || 'auto'}
-            tabs={[
-              {
-                key: 'auto',
-                node: translate('onboarding.import_organization', state.almApplication.key)
-              },
-              {
-                key: 'manual',
-                node: translate('onboarding.create_organization.create_manually')
-              }
-            ]}
-          />
-        )}
+        <Tabs<TabKeys>
+          onChange={this.onTabChange}
+          selected={tab || 'auto'}
+          tabs={[
+            {
+              key: 'auto',
+              node: translate('onboarding.import_organization', almApplication.key)
+            },
+            {
+              key: 'manual',
+              node: translate('onboarding.create_organization.create_manually')
+            }
+          ]}
+        />
 
         <ManualOrganizationCreate
-          className={classNames({ hidden: tab !== 'manual' && this.hasAutoImport(state, paid) })}
+          {...commonProps}
+          className={classNames({ hidden: tab !== 'manual' && this.hasAutoImport(state) })}
           createOrganization={this.props.createOrganization}
-          deleteOrganization={this.props.deleteOrganization}
-          onOrgCreated={this.handleOrgCreated}
+          onUpgradeFail={this.deleteOrganization}
           onlyPaid={paid}
-          subscriptionPlans={this.state.subscriptionPlans}
         />
 
-        {this.hasAutoImport(state, paid) && (
+        {almInstallId && almOrganization && !boundOrganization ? (
           <AutoOrganizationCreate
-            almApplication={state.almApplication}
+            {...commonProps}
+            almApplication={almApplication}
             almInstallId={almInstallId}
             almOrganization={almOrganization}
-            almUnboundApplications={this.state.almUnboundApplications}
-            boundOrganization={this.state.boundOrganization}
             className={classNames({ hidden: tab !== 'auto' })}
             createOrganization={this.props.createOrganization}
+            handleCancelImport={this.handleCancelImport}
             onOrgCreated={this.handleOrgCreated}
+            onUpgradeFail={this.deleteOrganization}
             unboundOrganizations={this.props.userOrganizations.filter(
               ({ actions = {}, alm, key }) =>
                 !alm && key !== currentUser.personalOrganization && actions.admin
             )}
-            updateUrlQuery={this.updateUrlQuery}
+          />
+        ) : (
+          <RemoteOrganizationChoose
+            almApplication={almApplication}
+            almInstallId={almInstallId}
+            almOrganization={almOrganization}
+            almUnboundApplications={state.almUnboundApplications}
+            boundOrganization={boundOrganization}
+            className={classNames({ hidden: tab !== 'auto' })}
           />
         )}
       </>
@@ -387,18 +451,18 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   }
 }
 
-function createOrganization(organization: T.OrganizationBase & { installationId?: string }) {
+function createOrganization(organization: T.Organization & { installationId?: string }) {
   return (dispatch: Dispatch) => {
-    return api.createOrganization(organization).then((organization: T.Organization) => {
-      dispatch(actions.createOrganization(organization));
-      return organization;
-    });
+    return api
+      .createOrganization({ ...organization, name: organization.name || organization.key })
+      .then((organization: T.Organization) => {
+        dispatch(actions.createOrganization(organization));
+        return organization.key;
+      });
   };
 }
 
-function updateOrganization(
-  organization: T.OrganizationBase & { key: string; installationId?: string }
-) {
+function updateOrganization(organization: T.Organization & { installationId?: string }) {
   return (dispatch: Dispatch) => {
     const { key, installationId, ...changes } = organization;
     const promises = [api.updateOrganization(key, changes)];
@@ -407,7 +471,7 @@ function updateOrganization(
     }
     return Promise.all(promises).then(() => {
       dispatch(actions.updateOrganization(key, changes));
-      return organization;
+      return organization.key;
     });
   };
 }
index ea69f4ad3a5b62dce93d1bae4369f8db52d188df..45afee9f63c605a1a13a1f7882bcef8a0c74723f 100644 (file)
@@ -21,113 +21,54 @@ import * as React from 'react';
 import OrganizationDetailsForm from './OrganizationDetailsForm';
 import OrganizationDetailsStep from './OrganizationDetailsStep';
 import PlanStep from './PlanStep';
-import { formatPrice } from './utils';
+import { Step } from './utils';
 import { translate } from '../../../helpers/l10n';
 
 interface Props {
-  createOrganization: (organization: T.OrganizationBase) => Promise<T.Organization>;
+  createOrganization: (organization: T.Organization) => Promise<string>;
   className?: string;
-  deleteOrganization: (key: string) => Promise<void>;
-  onOrgCreated: (organization: string) => void;
+  onUpgradeFail: () => void;
+  handleOrgDetailsFinish: (organization: T.Organization) => Promise<void>;
+  handleOrgDetailsStepOpen: () => void;
+  onDone: () => void;
   onlyPaid?: boolean;
-  subscriptionPlans?: T.SubscriptionPlan[];
-}
-
-enum Step {
-  OrganizationDetails,
-  Plan
-}
-
-interface State {
   organization?: T.Organization;
   step: Step;
+  subscriptionPlans?: T.SubscriptionPlan[];
 }
 
-export default class ManualOrganizationCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { step: Step.OrganizationDetails };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  handleOrganizationDetailsStepOpen = () => {
-    this.setState({ step: Step.OrganizationDetails });
-  };
-
-  handleOrganizationDetailsFinish = (organization: Required<T.OrganizationBase>) => {
-    this.setState({ organization, step: Step.Plan });
-    return Promise.resolve();
-  };
-
-  handlePaidPlanChoose = () => {
-    if (this.state.organization) {
-      this.props.onOrgCreated(this.state.organization.key);
-    }
-  };
-
-  handleFreePlanChoose = () => {
-    return this.createOrganization().then(key => {
-      this.props.onOrgCreated(key);
-    });
-  };
-
-  createOrganization = () => {
-    const { organization } = this.state;
-    if (organization) {
-      return this.props
-        .createOrganization({
-          avatar: organization.avatar,
-          description: organization.description,
-          key: organization.key,
-          name: organization.name || organization.key,
-          url: organization.url
-        })
-        .then(({ key }) => key);
-    } else {
+export default class ManualOrganizationCreate extends React.PureComponent<Props> {
+  handleCreateOrganization = () => {
+    const { organization } = this.props;
+    if (!organization) {
       return Promise.reject();
     }
-  };
-
-  deleteOrganization = () => {
-    const { organization } = this.state;
-    if (organization) {
-      this.props.deleteOrganization(organization.key).catch(() => {});
-    }
+    return this.props.createOrganization(organization);
   };
 
   render() {
-    const { className, subscriptionPlans } = this.props;
-    const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price;
-    const formattedPrice = formatPrice(startedPrice);
-
+    const { className, organization, subscriptionPlans } = this.props;
     return (
       <div className={className}>
         <OrganizationDetailsStep
-          finished={this.state.organization !== undefined}
-          onOpen={this.handleOrganizationDetailsStepOpen}
-          open={this.state.step === Step.OrganizationDetails}
-          organization={this.state.organization}>
+          finished={organization !== undefined}
+          onOpen={this.props.handleOrgDetailsStepOpen}
+          open={this.props.step === Step.OrganizationDetails}
+          organization={organization}>
           <OrganizationDetailsForm
-            onContinue={this.handleOrganizationDetailsFinish}
-            organization={this.state.organization}
+            onContinue={this.props.handleOrgDetailsFinish}
+            organization={organization}
             submitText={translate('continue')}
           />
         </OrganizationDetailsStep>
 
         {subscriptionPlans !== undefined && (
           <PlanStep
-            createOrganization={this.createOrganization}
-            deleteOrganization={this.deleteOrganization}
-            onFreePlanChoose={this.handleFreePlanChoose}
-            onPaidPlanChoose={this.handlePaidPlanChoose}
+            createOrganization={this.handleCreateOrganization}
+            onDone={this.props.onDone}
+            onUpgradeFail={this.props.onUpgradeFail}
             onlyPaid={this.props.onlyPaid}
-            open={this.state.step === Step.Plan}
-            startingPrice={formattedPrice}
+            open={this.props.step === Step.Plan}
             subscriptionPlans={subscriptionPlans}
           />
         )}
index a3dbc4e49a90a2e2f2729149ef1132db150a6b22..835d31f912bec8dc0dca6632375bf42f80d892e5 100644 (file)
@@ -32,8 +32,8 @@ type RequiredOrganization = Required<T.OrganizationBase>;
 
 interface Props {
   keyReadOnly?: boolean;
-  onContinue: (organization: RequiredOrganization) => Promise<void>;
-  organization?: T.OrganizationBase & { key: string };
+  onContinue: (organization: T.Organization) => Promise<void>;
+  organization?: T.Organization;
   submitText: string;
 }
 
@@ -108,7 +108,7 @@ export default class OrganizationDetailsForm extends React.PureComponent<Props,
     this.setState({ url });
   };
 
-  handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+  handleSubmit = (event: React.FormEvent) => {
     event.preventDefault();
     const { state } = this;
     if (this.canSubmit(state)) {
index 978b7faa200e8d6afcc55c23e763a3158c0a3d7e..acd98cf23ad88e82ba97c50a0991f7820df205e9 100644 (file)
@@ -27,7 +27,8 @@ interface Props {
   finished: boolean;
   onOpen: () => void;
   open: boolean;
-  organization?: T.OrganizationBase & { key: string };
+  organization?: T.Organization;
+  stepTitle?: string;
 }
 export default class OrganizationDetailsStep extends React.PureComponent<Props> {
   renderForm = () => {
@@ -53,7 +54,9 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props>
         renderForm={this.renderForm}
         renderResult={this.renderResult}
         stepNumber={1}
-        stepTitle={translate('onboarding.create_organization.enter_org_details')}
+        stepTitle={
+          this.props.stepTitle || translate('onboarding.create_organization.enter_org_details')
+        }
       />
     );
   }
index ffb191a0e6867dcfb2a52aa8405fe7c25850680a..59009a44b98bf38200eb5d465d2e0fe975e5a81e 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import BillingFormShim from './BillingFormShim';
 import PlanSelect, { Plan } from './PlanSelect';
+import { formatPrice } from './utils';
 import Step from '../../tutorials/components/Step';
 import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
 import { translate } from '../../../helpers/l10n';
@@ -31,12 +32,10 @@ const BillingForm = withCurrentUser(BillingFormShim);
 
 interface Props {
   createOrganization: () => Promise<string>;
-  deleteOrganization: () => void;
-  onFreePlanChoose: () => Promise<void>;
-  onPaidPlanChoose: () => void;
+  onDone: () => void;
+  onUpgradeFail?: () => void;
   onlyPaid?: boolean;
   open: boolean;
-  startingPrice: string;
   subscriptionPlans: T.SubscriptionPlan[];
 }
 
@@ -84,13 +83,19 @@ export default class PlanStep extends React.PureComponent<Props, State> {
     }
   };
 
-  handleFreePlanSubmit = () => {
+  handleFreePlanSubmit = (event: React.FormEvent) => {
+    event.preventDefault();
     this.setState({ submitting: true });
-    this.props.onFreePlanChoose().then(this.stopSubmitting, this.stopSubmitting);
+    return this.props.createOrganization().then(() => {
+      this.props.onDone();
+      this.stopSubmitting();
+    }, this.stopSubmitting);
   };
 
   renderForm = () => {
     const { submitting } = this.state;
+    const { subscriptionPlans } = this.props;
+    const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price;
     return (
       <div className="boxed-group-inner">
         {this.state.ready && (
@@ -99,18 +104,18 @@ export default class PlanStep extends React.PureComponent<Props, State> {
               <PlanSelect
                 onChange={this.handlePlanChange}
                 plan={this.state.plan}
-                startingPrice={this.props.startingPrice}
+                startingPrice={formatPrice(startedPrice)}
               />
             )}
 
             {this.state.plan === Plan.Paid ? (
               <BillingForm
-                onCommit={this.props.onPaidPlanChoose}
-                onFailToUpgrade={this.props.deleteOrganization}
+                onCommit={this.props.onDone}
+                onFailToUpgrade={this.props.onUpgradeFail}
                 organizationKey={this.props.createOrganization}
                 subscriptionPlans={this.props.subscriptionPlans}>
                 {({ onSubmit, renderFormFields, renderSubmitGroup }) => (
-                  <form onSubmit={onSubmit}>
+                  <form id="organization-paid-plan-form" onSubmit={onSubmit}>
                     {renderFormFields()}
                     <div className="billing-input-large big-spacer-top">
                       {renderSubmitGroup(
@@ -121,12 +126,15 @@ export default class PlanStep extends React.PureComponent<Props, State> {
                 )}
               </BillingForm>
             ) : (
-              <div className="display-flex-center big-spacer-top">
-                <SubmitButton disabled={submitting} onClick={this.handleFreePlanSubmit}>
+              <form
+                className="display-flex-center big-spacer-top"
+                id="organization-free-plan-form"
+                onSubmit={this.handleFreePlanSubmit}>
+                <SubmitButton disabled={submitting}>
                   {translate('my_account.create_organization')}
                 </SubmitButton>
                 {submitting && <DeferredSpinner className="spacer-left" />}
-              </div>
+              </form>
             )}
           </>
         )}
index 199fa8899e98121796a44bb0b0a676ff651edb28..fcf1b9c415f44d5584777f248b429800748331be 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import * as classNames from 'classnames';
 import { WithRouterProps, withRouter } from 'react-router';
 import { FormattedMessage } from 'react-intl';
 import { sortBy } from 'lodash';
@@ -38,6 +39,7 @@ interface Props {
   almOrganization?: T.AlmOrganization;
   almUnboundApplications: T.AlmUnboundApplication[];
   boundOrganization?: T.OrganizationBase;
+  className?: string;
 }
 
 interface State {
@@ -91,102 +93,108 @@ export class RemoteOrganizationChoose extends React.PureComponent<Props & WithRo
       almInstallId,
       almOrganization,
       almUnboundApplications,
-      boundOrganization
+      boundOrganization,
+      className
     } = this.props;
     const { unboundInstallationId } = this.state;
     return (
-      <div className="boxed-group-inner">
-        {almInstallId &&
-          !almOrganization && (
-            <Alert className="big-spacer-bottom width-60" variant="error">
-              <div className="markdown">
-                {translate('onboarding.import_organization.org_not_found')}
-                <ul>
-                  <li>{translate('onboarding.import_organization.org_not_found.tips_1')}</li>
-                  <li>{translate('onboarding.import_organization.org_not_found.tips_2')}</li>
-                </ul>
-              </div>
-            </Alert>
-          )}
-        {almOrganization &&
-          boundOrganization && (
-            <Alert className="big-spacer-bottom width-60" variant="error">
-              <FormattedMessage
-                defaultMessage={translate('onboarding.import_organization.already_bound_x')}
-                id="onboarding.import_organization.already_bound_x"
-                values={{
-                  avatar: (
-                    <img
-                      alt={almApplication.name}
-                      className="little-spacer-left"
-                      src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(
+      <div className={classNames('boxed-group', className)}>
+        <div className="boxed-group-header">
+          <h2>{translate('onboarding.import_organization.import_org_details')}</h2>
+        </div>
+        <div className="boxed-group-inner">
+          {almInstallId &&
+            !almOrganization && (
+              <Alert className="big-spacer-bottom width-60" variant="error">
+                <div className="markdown">
+                  {translate('onboarding.import_organization.org_not_found')}
+                  <ul>
+                    <li>{translate('onboarding.import_organization.org_not_found.tips_1')}</li>
+                    <li>{translate('onboarding.import_organization.org_not_found.tips_2')}</li>
+                  </ul>
+                </div>
+              </Alert>
+            )}
+          {almOrganization &&
+            boundOrganization && (
+              <Alert className="big-spacer-bottom width-60" variant="error">
+                <FormattedMessage
+                  defaultMessage={translate('onboarding.import_organization.already_bound_x')}
+                  id="onboarding.import_organization.already_bound_x"
+                  values={{
+                    avatar: (
+                      <img
+                        alt={almApplication.name}
+                        className="little-spacer-left"
+                        src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(
+                          almApplication.key
+                        )}.svg`}
+                        width={16}
+                      />
+                    ),
+                    name: <strong>{almOrganization.name}</strong>,
+                    boundAvatar: (
+                      <OrganizationAvatar
+                        className="little-spacer-left"
+                        organization={boundOrganization}
+                        small={true}
+                      />
+                    ),
+                    boundName: <strong>{boundOrganization.name}</strong>
+                  }}
+                />
+              </Alert>
+            )}
+          <div className="display-flex-center">
+            <div className="display-inline-block">
+              <IdentityProviderLink
+                className="display-inline-block"
+                identityProvider={almApplication}
+                onClick={this.handleInstallAppClick}
+                small={true}
+                url={almApplication.installationUrl}>
+                {translate(
+                  'onboarding.import_organization.choose_organization_button',
+                  almApplication.key
+                )}
+              </IdentityProviderLink>
+            </div>
+            {almUnboundApplications.length > 0 && (
+              <div className="display-flex-stretch">
+                <div className="vertical-pipe-separator">
+                  <div className="vertical-separator " />
+                  <span className="note">{translate('or')}</span>
+                  <div className="vertical-separator" />
+                </div>
+                <form className="big-spacer-top big-spacer-bottom" onSubmit={this.handleSubmit}>
+                  <div className="form-field abs-width-400">
+                    <label htmlFor="select-unbound-installation">
+                      {translate(
+                        'onboarding.import_organization.choose_unbound_installation',
                         almApplication.key
-                      )}.svg`}
-                      width={16}
+                      )}
+                    </label>
+                    <Select
+                      className="input-super-large"
+                      clearable={false}
+                      id="select-unbound-installation"
+                      labelKey="name"
+                      onChange={this.handleInstallationChange}
+                      optionRenderer={this.renderOption}
+                      options={sortBy(almUnboundApplications, o => o.name.toLowerCase())}
+                      placeholder={translate('onboarding.import_organization.choose_organization')}
+                      value={unboundInstallationId}
+                      valueKey="installationId"
+                      valueRenderer={this.renderOption}
                     />
-                  ),
-                  name: <strong>{almOrganization.name}</strong>,
-                  boundAvatar: (
-                    <OrganizationAvatar
-                      className="little-spacer-left"
-                      organization={boundOrganization}
-                      small={true}
-                    />
-                  ),
-                  boundName: <strong>{boundOrganization.name}</strong>
-                }}
-              />
-            </Alert>
-          )}
-        <div className="display-flex-center">
-          <div className="display-inline-block">
-            <IdentityProviderLink
-              className="display-inline-block"
-              identityProvider={almApplication}
-              onClick={this.handleInstallAppClick}
-              small={true}
-              url={almApplication.installationUrl}>
-              {translate(
-                'onboarding.import_organization.choose_organization_button',
-                almApplication.key
-              )}
-            </IdentityProviderLink>
-          </div>
-          {almUnboundApplications.length > 0 && (
-            <div className="display-flex-stretch">
-              <div className="vertical-pipe-separator">
-                <div className="vertical-separator " />
-                <span className="note">{translate('or')}</span>
-                <div className="vertical-separator" />
+                  </div>
+                  <SubmitButton disabled={!unboundInstallationId}>
+                    {translate('continue')}
+                  </SubmitButton>
+                </form>
               </div>
-              <form className="big-spacer-top big-spacer-bottom" onSubmit={this.handleSubmit}>
-                <div className="form-field abs-width-400">
-                  <label htmlFor="select-unbound-installation">
-                    {translate(
-                      'onboarding.import_organization.choose_unbound_installation',
-                      almApplication.key
-                    )}
-                  </label>
-                  <Select
-                    className="input-super-large"
-                    clearable={false}
-                    id="select-unbound-installation"
-                    labelKey="name"
-                    onChange={this.handleInstallationChange}
-                    optionRenderer={this.renderOption}
-                    options={sortBy(almUnboundApplications, o => o.name.toLowerCase())}
-                    placeholder={translate('onboarding.import_organization.choose_organization')}
-                    value={unboundInstallationId}
-                    valueKey="installationId"
-                    valueRenderer={this.renderOption}
-                  />
-                </div>
-                <SubmitButton disabled={!unboundInstallationId}>
-                  {translate('continue')}
-                </SubmitButton>
-              </form>
-            </div>
-          )}
+            )}
+          </div>
         </div>
       </div>
     );
index aaed1cd3a24c7e0160af8011a3935c34902c54a1..d4c4ed73bc8f5209a2f1d7b72c2b11a7b8efd9bc 100644 (file)
@@ -22,6 +22,7 @@ import { shallow } from 'enzyme';
 import AutoOrganizationCreate from '../AutoOrganizationCreate';
 import { waitAndUpdate, click } from '../../../../helpers/testUtils';
 import { bindAlmOrganization } from '../../../../api/alm-integration';
+import { Step } from '../utils';
 
 jest.mock('../../../../api/alm-integration', () => ({
   bindAlmOrganization: jest.fn().mockResolvedValue({})
@@ -35,47 +36,32 @@ const organization = {
   url: 'http://example.com/foo'
 };
 
-it('should render with import org button', () => {
-  expect(shallowRender()).toMatchSnapshot();
-});
-
 it('should render prefilled and create org', async () => {
   const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
-  const onOrgCreated = jest.fn();
-  const wrapper = shallowRender({
-    almInstallId: 'id-foo',
-    almOrganization: { ...organization, personal: false },
-    createOrganization,
-    onOrgCreated
-  });
+  const handleOrgDetailsFinish = jest.fn();
+  const wrapper = shallowRender({ createOrganization, handleOrgDetailsFinish });
 
   expect(wrapper).toMatchSnapshot();
 
   wrapper.find('OrganizationDetailsForm').prop<Function>('onContinue')(organization);
   await waitAndUpdate(wrapper);
+  expect(handleOrgDetailsFinish).toBeCalled();
 
+  wrapper.setProps({ organization });
+  wrapper.find('PlanStep').prop<Function>('createOrganization')();
   expect(createOrganization).toBeCalledWith({ ...organization, installationId: 'id-foo' });
-  expect(onOrgCreated).toBeCalledWith('foo');
 });
 
 it('should allow to cancel org import', () => {
-  const updateUrlQuery = jest.fn().mockResolvedValue({ key: 'foo' });
-  const wrapper = shallowRender({
-    almInstallId: 'id-foo',
-    almOrganization: { ...organization, personal: false },
-    updateUrlQuery
-  });
+  const handleCancelImport = jest.fn().mockResolvedValue({ key: 'foo' });
+  const wrapper = shallowRender({ handleCancelImport });
 
   click(wrapper.find('DeleteButton'));
-  expect(updateUrlQuery).toBeCalledWith({ almInstallId: undefined, almKey: undefined });
+  expect(handleCancelImport).toBeCalled();
 });
 
 it('should display choice between import or creation', () => {
-  const wrapper = shallowRender({
-    almInstallId: 'id-foo',
-    almOrganization: { ...organization, personal: false },
-    unboundOrganizations: [organization]
-  });
+  const wrapper = shallowRender({ unboundOrganizations: [organization] });
   expect(wrapper).toMatchSnapshot();
 
   wrapper.find('RadioToggle').prop<Function>('onCheck')('create');
@@ -89,12 +75,7 @@ it('should display choice between import or creation', () => {
 
 it('should bind existing organization', async () => {
   const onOrgCreated = jest.fn();
-  const wrapper = shallowRender({
-    almInstallId: 'id-foo',
-    almOrganization: { ...organization, personal: false },
-    onOrgCreated,
-    unboundOrganizations: [organization]
-  });
+  const wrapper = shallowRender({ onOrgCreated, unboundOrganizations: [organization] });
 
   wrapper.find('RadioToggle').prop<Function>('onCheck')('bind');
   wrapper.update();
@@ -117,11 +98,18 @@ function shallowRender(props: Partial<AutoOrganizationCreate['props']> = {}) {
         key: 'bitbucket',
         name: 'BitBucket'
       }}
-      almUnboundApplications={[]}
+      almInstallId="id-foo"
+      almOrganization={{ ...organization, personal: false }}
       createOrganization={jest.fn()}
+      handleCancelImport={jest.fn()}
+      handleOrgDetailsFinish={jest.fn()}
+      handleOrgDetailsStepOpen={jest.fn()}
+      onDone={jest.fn()}
       onOrgCreated={jest.fn()}
+      onUpgradeFail={jest.fn()}
+      step={Step.OrganizationDetails}
+      subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]}
       unboundOrganizations={[]}
-      updateUrlQuery={jest.fn()}
       {...props}
     />
   );
index eeb1de2935d6da31bac7c02b5b0c63afcaa2db7b..fbb17c78fe27a2ba63630bc72961563cf11e2f4c 100644 (file)
@@ -21,16 +21,25 @@ import * as React from 'react';
 import { shallow } from 'enzyme';
 import AutoPersonalOrganizationBind from '../AutoPersonalOrganizationBind';
 import { waitAndUpdate, click } from '../../../../helpers/testUtils';
+import { Step } from '../utils';
 
 const personalOrg = { key: 'personalorg', name: 'Personal Org' };
+const almOrganization = {
+  avatar: 'http://example.com/avatar',
+  description: 'description-foo',
+  key: 'key-foo',
+  name: 'name-foo',
+  personal: true,
+  url: 'http://example.com/foo'
+};
 
 it('should render correctly', async () => {
   const updateOrganization = jest.fn().mockResolvedValue({ key: personalOrg.key });
-  const onOrgCreated = jest.fn();
+  const handleOrgDetailsFinish = jest.fn();
   const wrapper = shallowRender({
     almInstallId: 'id-foo',
     importPersonalOrg: personalOrg,
-    onOrgCreated,
+    handleOrgDetailsFinish,
     updateOrganization
   });
 
@@ -38,21 +47,23 @@ it('should render correctly', async () => {
 
   wrapper.find('OrganizationDetailsForm').prop<Function>('onContinue')(personalOrg);
   await waitAndUpdate(wrapper);
+  expect(handleOrgDetailsFinish).toBeCalled();
 
+  wrapper.setProps({ organization: personalOrg });
+  wrapper.find('PlanStep').prop<Function>('createOrganization')();
   expect(updateOrganization).toBeCalledWith({ ...personalOrg, installationId: 'id-foo' });
-  expect(onOrgCreated).toBeCalledWith(personalOrg.key);
 });
 
 it('should allow to cancel org import', () => {
-  const updateUrlQuery = jest.fn();
+  const handleCancelImport = jest.fn();
   const wrapper = shallowRender({
     almInstallId: 'id-foo',
     importPersonalOrg: personalOrg,
-    updateUrlQuery
+    handleCancelImport
   });
 
   click(wrapper.find('DeleteButton'));
-  expect(updateUrlQuery).toBeCalledWith({ almInstallId: undefined, almKey: undefined });
+  expect(handleCancelImport).toBeCalled();
 });
 
 function shallowRender(props: Partial<AutoPersonalOrganizationBind['props']> = {}) {
@@ -65,18 +76,15 @@ function shallowRender(props: Partial<AutoPersonalOrganizationBind['props']> = {
         key: 'bitbucket',
         name: 'BitBucket'
       }}
-      almOrganization={{
-        avatar: 'http://example.com/avatar',
-        description: 'description-foo',
-        key: 'key-foo',
-        name: 'name-foo',
-        personal: true,
-        url: 'http://example.com/foo'
-      }}
+      almOrganization={almOrganization}
+      handleCancelImport={jest.fn()}
+      handleOrgDetailsFinish={jest.fn()}
+      handleOrgDetailsStepOpen={jest.fn()}
       importPersonalOrg={{ key: 'personalorg', name: 'Personal Org' }}
-      onOrgCreated={jest.fn()}
+      onDone={jest.fn()}
+      step={Step.OrganizationDetails}
+      subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]}
       updateOrganization={jest.fn()}
-      updateUrlQuery={jest.fn()}
       {...props}
     />
   );
index f4471222b68ed8c941e21ad73f156ff21cc43480..8e7cbd3ec60f1f2eeb64ea040d33d3218cfdf523 100644 (file)
@@ -79,6 +79,15 @@ const user: T.LoggedInUser = {
   showOnboardingTutorial: false
 };
 
+const almOrganization = {
+  avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
+  key: 'Foo&Bar',
+  name: 'Foo & Bar',
+  personal: true
+};
+
+const boundOrganization = { key: 'foobar', name: 'Foo & Bar' };
+
 beforeEach(() => {
   (getAlmAppInfo as jest.Mock<any>).mockClear();
   (getAlmOrganization as jest.Mock<any>).mockClear();
@@ -144,15 +153,26 @@ it('should render with auto personal organization bind page', async () => {
   expect(wrapper).toMatchSnapshot();
 });
 
-it('should slugify and find a uniq organization key', async () => {
+it('should render with organization bind page', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
     almOrganization: {
-      avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
-      key: 'Foo&Bar',
-      name: 'Foo & Bar',
-      personal: true
+      key: 'foo',
+      name: 'Foo',
+      avatar: 'my-avatar',
+      personal: false
     }
   });
+  const wrapper = shallowRender({
+    currentUser: { ...user, externalProvider: 'github' },
+    location: { query: { installation_id: 'foo' } } as Location
+  });
+  expect(wrapper).toMatchSnapshot();
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should slugify and find a uniq organization key', async () => {
+  (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({ almOrganization });
   (getOrganizations as jest.Mock<any>).mockResolvedValueOnce({
     organizations: [{ key: 'foo-and-bar' }, { key: 'foo-and-bar-1' }]
   });
@@ -185,9 +205,9 @@ it('should switch tabs', async () => {
 
   (wrapper.find('Tabs').prop('onChange') as Function)('manual');
   expect(wrapper.find('ManualOrganizationCreate').hasClass('hidden')).toBeFalsy();
-  expect(wrapper.find('AutoOrganizationCreate').hasClass('hidden')).toBeTruthy();
+  expect(wrapper.find('withRouter(RemoteOrganizationChoose)').hasClass('hidden')).toBeTruthy();
   (wrapper.find('Tabs').prop('onChange') as Function)('auto');
-  expect(wrapper.find('AutoOrganizationCreate').hasClass('hidden')).toBeFalsy();
+  expect(wrapper.find('withRouter(RemoteOrganizationChoose)').hasClass('hidden')).toBeFalsy();
   expect(wrapper.find('ManualOrganizationCreate').hasClass('hidden')).toBeTruthy();
 });
 
@@ -195,9 +215,9 @@ it('should reload the alm organization when the url query changes', async () =>
   const wrapper = shallowRender({ currentUser: { ...user, externalProvider: 'github' } });
   await waitAndUpdate(wrapper);
   expect(getAlmOrganization).not.toHaveBeenCalled();
-  wrapper.setProps({ location: { query: { installation_id: 'foo' } } });
+  wrapper.setProps({ location: { query: { installation_id: 'foo' } } as Location });
   expect(getAlmOrganization).toHaveBeenCalledWith({ installationId: 'foo' });
-  wrapper.setProps({ location: { query: {} } });
+  wrapper.setProps({ location: { query: {} } as Location });
   expect(wrapper.state('almOrganization')).toBeUndefined();
   expect(listUnboundApplications).toHaveBeenCalledTimes(2);
 });
@@ -207,14 +227,14 @@ it('should redirect to organization page after creation', async () => {
   const wrapper = shallowRender({ router: mockRouter({ push }) });
   await waitAndUpdate(wrapper);
 
-  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  wrapper.setState({ organization: boundOrganization });
+  wrapper.instance().handleOrgCreated('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);
+  wrapper.instance().handleOrgCreated('foo', false);
   expect(push).toHaveBeenCalledWith({
     pathname: '/organizations/foo',
     state: { justCreated: false }
@@ -227,7 +247,7 @@ it('should redirect to projects creation page after creation', async () => {
   await waitAndUpdate(wrapper);
 
   (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
-  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  wrapper.instance().handleOrgCreated('foo');
   expect(get).toHaveBeenCalled();
   expect(remove).toHaveBeenCalled();
   expect(push).toHaveBeenCalledWith({
@@ -235,9 +255,11 @@ it('should redirect to projects creation page after creation', async () => {
     state: { organization: 'foo', tab: 'manual' }
   });
 
-  wrapper.setState({ almOrganization: { key: 'foo', name: 'Foo', avatar: 'my-avatar' } });
+  wrapper.setState({
+    almOrganization: { key: 'foo', name: 'Foo', avatar: 'my-avatar', personal: false }
+  });
   (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
-  wrapper.find('ManualOrganizationCreate').prop<Function>('onOrgCreated')('foo');
+  wrapper.instance().handleOrgCreated('foo');
   expect(push).toHaveBeenCalledWith({
     pathname: '/projects/create',
     state: { organization: 'foo', tab: 'auto' }
@@ -246,13 +268,8 @@ it('should redirect to projects creation page after creation', async () => {
 
 it('should display AutoOrganizationCreate with already bound organization', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization: {
-      avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
-      key: 'Foo&Bar',
-      name: 'Foo & Bar',
-      personal: true
-    },
-    boundOrganization: { key: 'foobar', name: 'Foo & Bar' }
+    almOrganization: { ...almOrganization, personal: false },
+    boundOrganization
   });
   (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
   const push = jest.fn();
@@ -266,7 +283,7 @@ it('should display AutoOrganizationCreate with already bound organization', asyn
   expect(remove).toHaveBeenCalled();
   expect(getAlmOrganization).toHaveBeenCalledWith({ installationId: 'foo' });
   expect(push).not.toHaveBeenCalled();
-  expect(wrapper.find('AutoOrganizationCreate').prop('boundOrganization')).toEqual({
+  expect(wrapper.find('withRouter(RemoteOrganizationChoose)').prop('boundOrganization')).toEqual({
     key: 'foobar',
     name: 'Foo & Bar'
   });
@@ -274,13 +291,8 @@ it('should display AutoOrganizationCreate with already bound organization', asyn
 
 it('should redirect to org page when already bound and no binding in progress', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization: {
-      avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
-      key: 'Foo&Bar',
-      name: 'Foo & Bar',
-      personal: true
-    },
-    boundOrganization: { key: 'foobar', name: 'Foo & Bar' }
+    almOrganization,
+    boundOrganization
   });
   const push = jest.fn();
   const wrapper = shallowRender({
@@ -293,8 +305,25 @@ it('should redirect to org page when already bound and no binding in progress',
   expect(push).toHaveBeenCalledWith({ pathname: '/organizations/foobar' });
 });
 
+it('should roll back after upgrade failure', async () => {
+  const deleteOrganization = jest.fn();
+  const wrapper = shallowRender({ deleteOrganization });
+  await waitAndUpdate(wrapper);
+  wrapper.setState({ organization: boundOrganization });
+  wrapper.find('ManualOrganizationCreate').prop<Function>('onUpgradeFail')();
+  expect(deleteOrganization).toBeCalled();
+});
+
+it('should cancel imports', async () => {
+  const push = jest.fn();
+  const wrapper = shallowRender({ router: mockRouter({ push }) });
+  await waitAndUpdate(wrapper);
+  wrapper.instance().handleCancelImport();
+  expect(push).toBeCalledWith({ query: {} });
+});
+
 function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
-  return shallow(
+  return shallow<CreateOrganization>(
     <CreateOrganization
       createOrganization={jest.fn()}
       currentUser={user}
index 1682a14601378395e391b8628c816cc5408ff8d4..3e7736019edb6dc2c07bf81ba16ea806f6d8ff56 100644 (file)
@@ -21,6 +21,7 @@ import * as React from 'react';
 import { shallow } from 'enzyme';
 import ManualOrganizationCreate from '../ManualOrganizationCreate';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { Step } from '../utils';
 
 const organization = {
   avatar: 'http://example.com/avatar',
@@ -32,20 +33,18 @@ const organization = {
 
 it('should render and create organization', async () => {
   const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
-  const onOrgCreated = jest.fn();
-  const wrapper = shallowRender({ createOrganization, onOrgCreated });
+  const onDone = jest.fn();
+  const handleOrgDetailsFinish = jest.fn();
+  const wrapper = shallowRender({ createOrganization, handleOrgDetailsFinish, onDone });
 
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
 
   wrapper.find('OrganizationDetailsForm').prop<Function>('onContinue')(organization);
   await waitAndUpdate(wrapper);
+  expect(handleOrgDetailsFinish).toHaveBeenCalled();
+  wrapper.setProps({ step: Step.Plan });
   expect(wrapper).toMatchSnapshot();
-
-  wrapper.find('PlanStep').prop<Function>('onFreePlanChoose')();
-  await waitAndUpdate(wrapper);
-  expect(createOrganization).toBeCalledWith(organization);
-  expect(onOrgCreated).toBeCalledWith('foo');
 });
 
 it('should preselect paid plan', async () => {
@@ -57,28 +56,15 @@ it('should preselect paid plan', async () => {
   expect(wrapper.find('PlanStep').prop('onlyPaid')).toBe(true);
 });
 
-it('should roll back after upgrade failure', async () => {
-  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
-  const deleteOrganization = jest.fn().mockResolvedValue(undefined);
-  const wrapper = shallowRender({ createOrganization, deleteOrganization });
-  await waitAndUpdate(wrapper);
-
-  wrapper.find('OrganizationDetailsForm').prop<Function>('onContinue')(organization);
-  await waitAndUpdate(wrapper);
-
-  wrapper.find('PlanStep').prop<Function>('createOrganization')();
-  expect(createOrganization).toBeCalledWith(organization);
-
-  wrapper.find('PlanStep').prop<Function>('deleteOrganization')();
-  expect(deleteOrganization).toBeCalledWith(organization.key);
-});
-
 function shallowRender(props: Partial<ManualOrganizationCreate['props']> = {}) {
   return shallow(
     <ManualOrganizationCreate
       createOrganization={jest.fn()}
-      deleteOrganization={jest.fn()}
-      onOrgCreated={jest.fn()}
+      handleOrgDetailsFinish={jest.fn()}
+      handleOrgDetailsStepOpen={jest.fn()}
+      onDone={jest.fn()}
+      onUpgradeFail={jest.fn()}
+      step={Step.OrganizationDetails}
       subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]}
       {...props}
     />
index 876a20f0bfd19e1d905c5afc9bb318ea5302700e..2334b42871f1c8b422d12c5326dd5dda03653647 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import PlanStep from '../PlanStep';
-import { waitAndUpdate, click } from '../../../../helpers/testUtils';
+import { waitAndUpdate, submit } from '../../../../helpers/testUtils';
 import { Plan } from '../PlanSelect';
 
 jest.mock('../../../../app/components/extensions/utils', () => ({
   getExtensionStart: jest.fn().mockResolvedValue(undefined)
 }));
 
+const subscriptionPlans = [{ maxNcloc: 1000, price: 100 }];
+
 it('should render and use free plan', async () => {
-  const onFreePlanChoose = jest.fn().mockResolvedValue(undefined);
+  const onDone = jest.fn();
+  const createOrganization = jest.fn().mockResolvedValue('org');
   const wrapper = shallow(
     <PlanStep
-      createOrganization={jest.fn().mockResolvedValue('org')}
-      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
-      onFreePlanChoose={onFreePlanChoose}
-      onPaidPlanChoose={jest.fn()}
+      createOrganization={createOrganization}
+      onDone={onDone}
+      onUpgradeFail={jest.fn()}
       open={true}
-      startingPrice="10"
-      subscriptionPlans={[]}
+      subscriptionPlans={subscriptionPlans}
     />
   );
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
   expect(wrapper.dive()).toMatchSnapshot();
 
-  click(wrapper.dive().find('SubmitButton'));
-  expect(onFreePlanChoose).toBeCalled();
+  submit(wrapper.dive().find('form'));
+  await waitAndUpdate(wrapper);
+  expect(createOrganization).toBeCalled();
+  expect(onDone).toBeCalled();
 });
 
 it('should upgrade', async () => {
-  const onPaidPlanChoose = jest.fn();
+  const onDone = jest.fn();
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn().mockResolvedValue('org')}
-      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
-      onFreePlanChoose={jest.fn().mockResolvedValue(undefined)}
-      onPaidPlanChoose={onPaidPlanChoose}
+      onDone={onDone}
+      onUpgradeFail={jest.fn()}
       open={true}
-      startingPrice="10"
-      subscriptionPlans={[]}
+      subscriptionPlans={subscriptionPlans}
     />
   );
   await waitAndUpdate(wrapper);
@@ -73,20 +74,18 @@ it('should upgrade', async () => {
     .dive()
     .find('Connect(withCurrentUser(BillingFormShim))')
     .prop<Function>('onCommit')();
-  expect(onPaidPlanChoose).toBeCalled();
+  expect(onDone).toBeCalled();
 });
 
 it('should preselect paid plan', async () => {
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn()}
-      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
-      onFreePlanChoose={jest.fn().mockResolvedValue(undefined)}
-      onPaidPlanChoose={jest.fn()}
+      onDone={jest.fn()}
+      onUpgradeFail={jest.fn()}
       onlyPaid={true}
       open={true}
-      startingPrice="10"
-      subscriptionPlans={[]}
+      subscriptionPlans={subscriptionPlans}
     />
   );
   await waitAndUpdate(wrapper);
index 985f767d6c7cdc6dde017e93bb58a849dc82640e..91da2875d7020b7f3d01534dcf70de317f5da20d 100644 (file)
@@ -1,18 +1,12 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should display choice between import or creation 1`] = `
-<div
-  className="boxed-group"
->
-  <div
-    className="boxed-group-header"
-  >
-    <h2>
-      onboarding.import_organization.import_org_details
-    </h2>
-  </div>
-  <div
-    className="boxed-group-inner"
+<div>
+  <OrganizationDetailsStep
+    finished={false}
+    onOpen={[MockFunction]}
+    open={true}
+    stepTitle="onboarding.import_organization.import_org_details"
   >
     <div
       className="huge-spacer-bottom"
@@ -39,7 +33,7 @@ exports[`should display choice between import or creation 1`] = `
         />
         <DeleteButton
           className="little-spacer-left"
-          onClick={[Function]}
+          onClick={[MockFunction]}
         />
       </p>
       <RadioToggle
@@ -61,23 +55,36 @@ exports[`should display choice between import or creation 1`] = `
         value={null}
       />
     </div>
-  </div>
+  </OrganizationDetailsStep>
+  <PlanStep
+    createOrganization={[Function]}
+    onDone={[MockFunction]}
+    onUpgradeFail={[MockFunction]}
+    onlyPaid={false}
+    open={false}
+    subscriptionPlans={
+      Array [
+        Object {
+          "maxNcloc": 100000,
+          "price": 10,
+        },
+        Object {
+          "maxNcloc": 250000,
+          "price": 75,
+        },
+      ]
+    }
+  />
 </div>
 `;
 
 exports[`should render prefilled and create org 1`] = `
-<div
-  className="boxed-group"
->
-  <div
-    className="boxed-group-header"
-  >
-    <h2>
-      onboarding.import_organization.import_org_details
-    </h2>
-  </div>
-  <div
-    className="boxed-group-inner"
+<div>
+  <OrganizationDetailsStep
+    finished={false}
+    onOpen={[MockFunction]}
+    open={true}
+    stepTitle="onboarding.import_organization.import_org_details"
   >
     <div
       className="huge-spacer-bottom"
@@ -104,12 +111,12 @@ exports[`should render prefilled and create org 1`] = `
         />
         <DeleteButton
           className="little-spacer-left"
-          onClick={[Function]}
+          onClick={[MockFunction]}
         />
       </p>
     </div>
     <OrganizationDetailsForm
-      onContinue={[Function]}
+      onContinue={[MockFunction]}
       organization={
         Object {
           "avatar": "http://example.com/avatar",
@@ -120,34 +127,27 @@ exports[`should render prefilled and create org 1`] = `
           "url": "http://example.com/foo",
         }
       }
-      submitText="onboarding.import_organization.import"
+      submitText="continue"
     />
-  </div>
-</div>
-`;
-
-exports[`should render with import org button 1`] = `
-<div
-  className="boxed-group"
->
-  <div
-    className="boxed-group-header"
-  >
-    <h2>
-      onboarding.import_organization.import_org_details
-    </h2>
-  </div>
-  <withRouter(RemoteOrganizationChoose)
-    almApplication={
-      Object {
-        "backgroundColor": "#0052CC",
-        "iconPath": "\\"/static/authbitbucket/bitbucket.svg\\"",
-        "installationUrl": "https://bitbucket.org/install/app",
-        "key": "bitbucket",
-        "name": "BitBucket",
-      }
+  </OrganizationDetailsStep>
+  <PlanStep
+    createOrganization={[Function]}
+    onDone={[MockFunction]}
+    onUpgradeFail={[MockFunction]}
+    onlyPaid={false}
+    open={false}
+    subscriptionPlans={
+      Array [
+        Object {
+          "maxNcloc": 100000,
+          "price": 10,
+        },
+        Object {
+          "maxNcloc": 250000,
+          "price": 75,
+        },
+      ]
     }
-    almUnboundApplications={Array []}
   />
 </div>
 `;
index b1487a7738b09b751b5c4cda04e746be9f153658..cece5c58e9aa9a51795fb4e5215139c3ddb4395c 100644 (file)
@@ -1,11 +1,12 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should render correctly 1`] = `
-<div
-  className="boxed-group"
->
-  <div
-    className="boxed-group-inner"
+<Fragment>
+  <OrganizationDetailsStep
+    finished={false}
+    onOpen={[MockFunction]}
+    open={true}
+    stepTitle="onboarding.import_organization.personal.import_org_details"
   >
     <div
       className="display-flex-center big-spacer-bottom"
@@ -41,7 +42,7 @@ exports[`should render correctly 1`] = `
       />
       <DeleteButton
         className="little-spacer-left"
-        onClick={[Function]}
+        onClick={[MockFunction]}
       />
     </div>
     <OrganizationDetailsForm
@@ -53,8 +54,26 @@ exports[`should render correctly 1`] = `
           "name": "Personal Org",
         }
       }
-      submitText="onboarding.import_organization.bind"
+      submitText="continue"
     />
-  </div>
-</div>
+  </OrganizationDetailsStep>
+  <PlanStep
+    createOrganization={[Function]}
+    onDone={[MockFunction]}
+    onlyPaid={false}
+    open={false}
+    subscriptionPlans={
+      Array [
+        Object {
+          "maxNcloc": 100000,
+          "price": 10,
+        },
+        Object {
+          "maxNcloc": 250000,
+          "price": 75,
+        },
+      ]
+    }
+  />
+</Fragment>
 `;
index 86a76be9310a9ffa944b9c9b3352a605323ec12f..e69bd5536b6d36958d92beaf06f42d1ebea24cb7 100644 (file)
@@ -45,6 +45,9 @@ exports[`should render with auto personal organization bind page 2`] = `
           "personal": true,
         }
       }
+      handleCancelImport={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
       importPersonalOrg={
         Object {
           "actions": Object {
@@ -54,9 +57,21 @@ exports[`should render with auto personal organization bind page 2`] = `
           "name": "Foo",
         }
       }
-      onOrgCreated={[Function]}
+      onDone={[Function]}
+      step={0}
+      subscriptionPlans={
+        Array [
+          Object {
+            "maxNcloc": 100000,
+            "price": 10,
+          },
+          Object {
+            "maxNcloc": 250000,
+            "price": 75,
+          },
+        ]
+      }
       updateOrganization={[MockFunction]}
-      updateUrlQuery={[Function]}
     />
   </div>
 </Fragment>
@@ -123,8 +138,11 @@ exports[`should render with auto tab displayed 1`] = `
     <ManualOrganizationCreate
       className="hidden"
       createOrganization={[MockFunction]}
-      deleteOrganization={[MockFunction]}
-      onOrgCreated={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
       subscriptionPlans={
         Array [
           Object {
@@ -138,7 +156,7 @@ exports[`should render with auto tab displayed 1`] = `
         ]
       }
     />
-    <AutoOrganizationCreate
+    <withRouter(RemoteOrganizationChoose)
       almApplication={
         Object {
           "backgroundColor": "blue",
@@ -150,20 +168,6 @@ exports[`should render with auto tab displayed 1`] = `
       }
       almUnboundApplications={Array []}
       className=""
-      createOrganization={[MockFunction]}
-      onOrgCreated={[Function]}
-      unboundOrganizations={
-        Array [
-          Object {
-            "actions": Object {
-              "admin": true,
-            },
-            "key": "foo",
-            "name": "Foo",
-          },
-        ]
-      }
-      updateUrlQuery={[Function]}
     />
   </div>
 </Fragment>
@@ -236,8 +240,11 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
     <ManualOrganizationCreate
       className="hidden"
       createOrganization={[MockFunction]}
-      deleteOrganization={[MockFunction]}
-      onOrgCreated={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
       subscriptionPlans={
         Array [
           Object {
@@ -272,10 +279,27 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
           "url": "https://www.sonarsource.com",
         }
       }
-      almUnboundApplications={Array []}
       className=""
       createOrganization={[MockFunction]}
+      handleCancelImport={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
       onOrgCreated={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
+      subscriptionPlans={
+        Array [
+          Object {
+            "maxNcloc": 100000,
+            "price": 10,
+          },
+          Object {
+            "maxNcloc": 250000,
+            "price": 75,
+          },
+        ]
+      }
       unboundOrganizations={
         Array [
           Object {
@@ -287,7 +311,6 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
           },
         ]
       }
-      updateUrlQuery={[Function]}
     />
   </div>
 </Fragment>
@@ -336,10 +359,12 @@ exports[`should render with manual tab displayed 1`] = `
       </p>
     </header>
     <ManualOrganizationCreate
-      className=""
       createOrganization={[MockFunction]}
-      deleteOrganization={[MockFunction]}
-      onOrgCreated={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
       subscriptionPlans={
         Array [
           Object {
@@ -357,7 +382,13 @@ exports[`should render with manual tab displayed 1`] = `
 </Fragment>
 `;
 
-exports[`should switch tabs 1`] = `
+exports[`should render with organization bind page 1`] = `
+<AlmApplicationInstalling
+  almKey="github"
+/>
+`;
+
+exports[`should render with organization bind page 2`] = `
 <Fragment>
   <HelmetWrapper
     defer={true}
@@ -418,8 +449,11 @@ exports[`should switch tabs 1`] = `
     <ManualOrganizationCreate
       className="hidden"
       createOrganization={[MockFunction]}
-      deleteOrganization={[MockFunction]}
-      onOrgCreated={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
       subscriptionPlans={
         Array [
           Object {
@@ -443,10 +477,36 @@ exports[`should switch tabs 1`] = `
           "name": "GitHub",
         }
       }
-      almUnboundApplications={Array []}
+      almInstallId="foo"
+      almOrganization={
+        Object {
+          "avatar": "my-avatar",
+          "key": "foo",
+          "name": "Foo",
+          "personal": false,
+        }
+      }
       className=""
       createOrganization={[MockFunction]}
+      handleCancelImport={[Function]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
       onOrgCreated={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
+      subscriptionPlans={
+        Array [
+          Object {
+            "maxNcloc": 100000,
+            "price": 10,
+          },
+          Object {
+            "maxNcloc": 250000,
+            "price": 75,
+          },
+        ]
+      }
       unboundOrganizations={
         Array [
           Object {
@@ -458,7 +518,102 @@ exports[`should switch tabs 1`] = `
           },
         ]
       }
-      updateUrlQuery={[Function]}
+    />
+  </div>
+</Fragment>
+`;
+
+exports[`should switch tabs 1`] = `
+<Fragment>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="onboarding.create_organization.page.header"
+    titleTemplate="%s"
+  />
+  <div
+    className="sonarcloud page page-limited"
+  >
+    <header
+      className="page-header"
+    >
+      <h1
+        className="page-title big-spacer-bottom"
+      >
+        onboarding.create_organization.page.header
+      </h1>
+      <p
+        className="page-description"
+      >
+        <FormattedMessage
+          defaultMessage="onboarding.create_organization.page.description"
+          id="onboarding.create_organization.page.description"
+          values={
+            Object {
+              "break": <br />,
+              "more": <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                target="_blank"
+                to="/documentation/sonarcloud-pricing/"
+              >
+                learn_more
+              </Link>,
+              "price": "billing.price_format.10",
+            }
+          }
+        />
+      </p>
+    </header>
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
+        Array [
+          Object {
+            "key": "auto",
+            "node": "onboarding.import_organization.github",
+          },
+          Object {
+            "key": "manual",
+            "node": "onboarding.create_organization.create_manually",
+          },
+        ]
+      }
+    />
+    <ManualOrganizationCreate
+      className="hidden"
+      createOrganization={[MockFunction]}
+      handleOrgDetailsFinish={[Function]}
+      handleOrgDetailsStepOpen={[Function]}
+      onDone={[Function]}
+      onUpgradeFail={[Function]}
+      step={0}
+      subscriptionPlans={
+        Array [
+          Object {
+            "maxNcloc": 100000,
+            "price": 10,
+          },
+          Object {
+            "maxNcloc": 250000,
+            "price": 75,
+          },
+        ]
+      }
+    />
+    <withRouter(RemoteOrganizationChoose)
+      almApplication={
+        Object {
+          "backgroundColor": "blue",
+          "iconPath": "icon/path",
+          "installationUrl": "https://alm.installation.url",
+          "key": "github",
+          "name": "GitHub",
+        }
+      }
+      almUnboundApplications={Array []}
+      className=""
     />
   </div>
 </Fragment>
index d538064e72cc740a673e02b779dde4155f099cbb..c9fab6555046667c27c08f7240d8c2e6ccfedc12 100644 (file)
@@ -4,21 +4,19 @@ exports[`should render and create organization 1`] = `
 <div>
   <OrganizationDetailsStep
     finished={false}
-    onOpen={[Function]}
+    onOpen={[MockFunction]}
     open={true}
   >
     <OrganizationDetailsForm
-      onContinue={[Function]}
+      onContinue={[MockFunction]}
       submitText="continue"
     />
   </OrganizationDetailsStep>
   <PlanStep
     createOrganization={[Function]}
-    deleteOrganization={[Function]}
-    onFreePlanChoose={[Function]}
-    onPaidPlanChoose={[Function]}
+    onDone={[MockFunction]}
+    onUpgradeFail={[MockFunction]}
     open={false}
-    startingPrice="billing.price_format.10"
     subscriptionPlans={
       Array [
         Object {
@@ -38,28 +36,30 @@ exports[`should render and create organization 1`] = `
 exports[`should render and create organization 2`] = `
 <div>
   <OrganizationDetailsStep
-    finished={true}
-    onOpen={[Function]}
+    finished={false}
+    onOpen={[MockFunction]}
     open={false}
-    organization={
-      Object {
-        "avatar": "http://example.com/avatar",
-        "description": "description-foo",
-        "key": "key-foo",
-        "name": "name-foo",
-        "url": "http://example.com/foo",
-      }
-    }
   >
     <OrganizationDetailsForm
-      onContinue={[Function]}
-      organization={
-        Object {
-          "avatar": "http://example.com/avatar",
-          "description": "description-foo",
-          "key": "key-foo",
-          "name": "name-foo",
-          "url": "http://example.com/foo",
+      onContinue={
+        [MockFunction] {
+          "calls": Array [
+            Array [
+              Object {
+                "avatar": "http://example.com/avatar",
+                "description": "description-foo",
+                "key": "key-foo",
+                "name": "name-foo",
+                "url": "http://example.com/foo",
+              },
+            ],
+          ],
+          "results": Array [
+            Object {
+              "isThrow": false,
+              "value": undefined,
+            },
+          ],
         }
       }
       submitText="continue"
@@ -67,11 +67,9 @@ exports[`should render and create organization 2`] = `
   </OrganizationDetailsStep>
   <PlanStep
     createOrganization={[Function]}
-    deleteOrganization={[Function]}
-    onFreePlanChoose={[Function]}
-    onPaidPlanChoose={[Function]}
+    onDone={[MockFunction]}
+    onUpgradeFail={[MockFunction]}
     open={true}
-    startingPrice="billing.price_format.10"
     subscriptionPlans={
       Array [
         Object {
index 80fad1f182b0f55b988a147baa10aae960a47228..b03f3c1ef55ac1b6d9647059d6f0f6e248eeb4b5 100644 (file)
@@ -38,7 +38,14 @@ exports[`should preselect paid plan 2`] = `
         onCommit={[MockFunction]}
         onFailToUpgrade={[MockFunction]}
         organizationKey={[MockFunction]}
-        subscriptionPlans={Array []}
+        subscriptionPlans={
+          Array [
+            Object {
+              "maxNcloc": 1000,
+              "price": 100,
+            },
+          ]
+        }
       >
         <Component />
       </Connect(withCurrentUser(BillingFormShim))>
@@ -84,18 +91,19 @@ exports[`should render and use free plan 2`] = `
       <PlanSelect
         onChange={[Function]}
         plan="free"
-        startingPrice="10"
+        startingPrice="billing.price_format.100"
       />
-      <div
+      <form
         className="display-flex-center big-spacer-top"
+        id="organization-free-plan-form"
+        onSubmit={[Function]}
       >
         <SubmitButton
           disabled={false}
-          onClick={[Function]}
         >
           my_account.create_organization
         </SubmitButton>
-      </div>
+      </form>
     </div>
   </div>
 </div>
@@ -126,13 +134,20 @@ exports[`should upgrade 1`] = `
       <PlanSelect
         onChange={[Function]}
         plan="paid"
-        startingPrice="10"
+        startingPrice="billing.price_format.100"
       />
       <Connect(withCurrentUser(BillingFormShim))
         onCommit={[MockFunction]}
         onFailToUpgrade={[MockFunction]}
         organizationKey={[MockFunction]}
-        subscriptionPlans={Array []}
+        subscriptionPlans={
+          Array [
+            Object {
+              "maxNcloc": 1000,
+              "price": 100,
+            },
+          ]
+        }
       >
         <Component />
       </Connect(withCurrentUser(BillingFormShim))>
index 92c500b06e7265d87041ebad05e50b0aaf621352..83c6ba9b4290acc2d376784640dec580fcbf1703 100644 (file)
@@ -62,90 +62,101 @@ exports[`should display an alert message 1`] = `
 
 exports[`should display unbound installations 1`] = `
 <div
-  className="boxed-group-inner"
+  className="boxed-group"
 >
   <div
-    className="display-flex-center"
+    className="boxed-group-header"
+  >
+    <h2>
+      onboarding.import_organization.import_org_details
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
   >
     <div
-      className="display-inline-block"
-    >
-      <IdentityProviderLink
-        className="display-inline-block"
-        identityProvider={
-          Object {
-            "backgroundColor": "blue",
-            "iconPath": "icon/path",
-            "installationUrl": "https://alm.application.url",
-            "key": "github",
-            "name": "GitHub",
-          }
-        }
-        onClick={[Function]}
-        small={true}
-        url="https://alm.application.url"
-      >
-        onboarding.import_organization.choose_organization_button.github
-      </IdentityProviderLink>
-    </div>
-    <div
-      className="display-flex-stretch"
+      className="display-flex-center"
     >
       <div
-        className="vertical-pipe-separator"
+        className="display-inline-block"
       >
-        <div
-          className="vertical-separator "
-        />
-        <span
-          className="note"
+        <IdentityProviderLink
+          className="display-inline-block"
+          identityProvider={
+            Object {
+              "backgroundColor": "blue",
+              "iconPath": "icon/path",
+              "installationUrl": "https://alm.application.url",
+              "key": "github",
+              "name": "GitHub",
+            }
+          }
+          onClick={[Function]}
+          small={true}
+          url="https://alm.application.url"
         >
-          or
-        </span>
-        <div
-          className="vertical-separator"
-        />
+          onboarding.import_organization.choose_organization_button.github
+        </IdentityProviderLink>
       </div>
-      <form
-        className="big-spacer-top big-spacer-bottom"
-        onSubmit={[Function]}
+      <div
+        className="display-flex-stretch"
       >
         <div
-          className="form-field abs-width-400"
+          className="vertical-pipe-separator"
         >
-          <label
-            htmlFor="select-unbound-installation"
+          <div
+            className="vertical-separator "
+          />
+          <span
+            className="note"
           >
-            onboarding.import_organization.choose_unbound_installation.github
-          </label>
-          <Select
-            className="input-super-large"
-            clearable={false}
-            id="select-unbound-installation"
-            labelKey="name"
-            onChange={[Function]}
-            optionRenderer={[Function]}
-            options={
-              Array [
-                Object {
-                  "installationId": "12345",
-                  "key": "foo",
-                  "name": "Foo",
-                },
-              ]
-            }
-            placeholder="onboarding.import_organization.choose_organization"
-            value=""
-            valueKey="installationId"
-            valueRenderer={[Function]}
+            or
+          </span>
+          <div
+            className="vertical-separator"
           />
         </div>
-        <SubmitButton
-          disabled={true}
+        <form
+          className="big-spacer-top big-spacer-bottom"
+          onSubmit={[Function]}
         >
-          continue
-        </SubmitButton>
-      </form>
+          <div
+            className="form-field abs-width-400"
+          >
+            <label
+              htmlFor="select-unbound-installation"
+            >
+              onboarding.import_organization.choose_unbound_installation.github
+            </label>
+            <Select
+              className="input-super-large"
+              clearable={false}
+              id="select-unbound-installation"
+              labelKey="name"
+              onChange={[Function]}
+              optionRenderer={[Function]}
+              options={
+                Array [
+                  Object {
+                    "installationId": "12345",
+                    "key": "foo",
+                    "name": "Foo",
+                  },
+                ]
+              }
+              placeholder="onboarding.import_organization.choose_organization"
+              value=""
+              valueKey="installationId"
+              valueRenderer={[Function]}
+            />
+          </div>
+          <SubmitButton
+            disabled={true}
+          >
+            continue
+          </SubmitButton>
+        </form>
+      </div>
     </div>
   </div>
 </div>
@@ -153,31 +164,42 @@ exports[`should display unbound installations 1`] = `
 
 exports[`should render 1`] = `
 <div
-  className="boxed-group-inner"
+  className="boxed-group"
 >
   <div
-    className="display-flex-center"
+    className="boxed-group-header"
+  >
+    <h2>
+      onboarding.import_organization.import_org_details
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
   >
     <div
-      className="display-inline-block"
+      className="display-flex-center"
     >
-      <IdentityProviderLink
+      <div
         className="display-inline-block"
-        identityProvider={
-          Object {
-            "backgroundColor": "blue",
-            "iconPath": "icon/path",
-            "installationUrl": "https://alm.application.url",
-            "key": "github",
-            "name": "GitHub",
-          }
-        }
-        onClick={[Function]}
-        small={true}
-        url="https://alm.application.url"
       >
-        onboarding.import_organization.choose_organization_button.github
-      </IdentityProviderLink>
+        <IdentityProviderLink
+          className="display-inline-block"
+          identityProvider={
+            Object {
+              "backgroundColor": "blue",
+              "iconPath": "icon/path",
+              "installationUrl": "https://alm.application.url",
+              "key": "github",
+              "name": "GitHub",
+            }
+          }
+          onClick={[Function]}
+          small={true}
+          url="https://alm.application.url"
+        >
+          onboarding.import_organization.choose_organization_button.github
+        </IdentityProviderLink>
+      </div>
     </div>
   </div>
 </div>
index 29d09d82fb1bcc6d65649b157eb2baf839ad83f3..bc2688be093dd0d780099d3ff42e21d412ae7afd 100644 (file)
@@ -35,6 +35,11 @@ export const ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP =
 export const ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP =
   'sonarcloud.import_org.redirect_to_projects';
 
+export enum Step {
+  OrganizationDetails,
+  Plan
+}
+
 export function formatPrice(price?: number, noSign?: boolean) {
   const priceFormatted = formatMeasure(price, 'FLOAT')
     .replace(/[.|,]0$/, '')
index c4cc99db4ad270f83175e9be5eb92e93ae16b209..5bca60ee0ee7abdbad9f0b403ce9424b072a21a2 100644 (file)
@@ -2798,6 +2798,7 @@ onboarding.import_organization.installing=Finalize installation of the ALM appli
 onboarding.import_organization.installing.bitbucket=Finalize installation of the Bitbucket application..
 onboarding.import_organization.installing.github=Finalize installation of the GitHub application...
 onboarding.import_organization.personal.page.header=Bind to your personal organization
+onboarding.import_organization.personal.import_org_details=Import personal organization details
 onboarding.import_organization.private.disabled=Selecting private repository is not available yet and will come soon. Meanwhile, you need to create the project manually.
 onboarding.import_organization.bitbucket=Import from BitBucket teams
 onboarding.import_organization.github=Import from GitHub organizations