]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-43 correctly handle upgrade errors
authorStas Vilchik <stas.vilchik@sonarsource.com>
Fri, 21 Sep 2018 08:32:12 +0000 (10:32 +0200)
committerSonarTech <sonartech@sonarsource.com>
Tue, 25 Sep 2018 18:21:00 +0000 (20:21 +0200)
13 files changed:
server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx
server/sonar-web/src/main/js/apps/create/organization/CardForm.tsx
server/sonar-web/src/main/js/apps/create/organization/CouponForm.tsx
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CardForm-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CouponForm-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__/PlanStep-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CardForm-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CouponForm-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__/PlanStep-test.tsx.snap

index bfa9c5440273343813a924038a5a6a4d3de780cd..f08e3ffc80e9f4c95fe229c9a084e342f20421c8 100644 (file)
@@ -46,6 +46,7 @@ interface Props {
   onClose: () => void;
   onCommit: () => void;
   onCouponUpdate?: (coupon?: Coupon) => void;
+  onFailToUpgrade?: () => void;
   organizationKey: string | (() => Promise<string>);
   skipBraintreeInit?: boolean;
   subscriptionPlans: SubscriptionPlan[];
index 221214668da7410cbf24fa96459e9c418375bf76..39a739f8ff36b5e0c2038b85405fd1cb95ad056f 100644 (file)
@@ -27,6 +27,7 @@ import { translate } from '../../../helpers/l10n';
 interface Props {
   createOrganization: () => Promise<string>;
   currentUser: CurrentUser;
+  onFailToUpgrade: () => void;
   onSubmit: () => void;
   subscriptionPlans: SubscriptionPlan[];
 }
@@ -43,6 +44,7 @@ export class CardForm extends React.PureComponent<Props> {
           currentUser={this.props.currentUser}
           onClose={this.handleClose}
           onCommit={this.props.onSubmit}
+          onFailToUpgrade={this.props.onFailToUpgrade}
           organizationKey={this.props.createOrganization}
           subscriptionPlans={this.props.subscriptionPlans}>
           {form => (
index 4dfa9cdaac76885621cda046671f9124188d3850..fc0a7ab28fef1022bff80d8c783130edf46b56a7 100644 (file)
@@ -28,6 +28,7 @@ import DocTooltip from '../../../components/docs/DocTooltip';
 interface Props {
   createOrganization: () => Promise<string>;
   currentUser: CurrentUser;
+  onFailToUpgrade: () => void;
   onSubmit: () => void;
 }
 
@@ -72,6 +73,7 @@ export class CouponForm extends React.PureComponent<Props, State> {
           onClose={this.handleClose}
           onCommit={this.props.onSubmit}
           onCouponUpdate={this.handleCouponUpdate}
+          onFailToUpgrade={this.props.onFailToUpgrade}
           organizationKey={this.props.createOrganization}
           skipBraintreeInit={true}
           subscriptionPlans={[]}>
index 7837907b045c1dbbf815dc32a17700b8a7f74832..78201f442d9e3b498fc1e41ae231a2761885219b 100644 (file)
@@ -22,20 +22,24 @@ import { Helmet } from 'react-helmet';
 import { FormattedMessage } from 'react-intl';
 import { Link, withRouter, WithRouterProps } from 'react-router';
 import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
 import OrganizationDetailsStep from './OrganizationDetailsStep';
 import PlanStep from './PlanStep';
 import { formatPrice } from './utils';
 import { whenLoggedIn } from './whenLoggedIn';
-import { createOrganization } from '../../organizations/actions';
 import { getSubscriptionPlans } from '../../../api/billing';
 import { OrganizationBase, Organization, SubscriptionPlan } from '../../../app/types';
 import { translate } from '../../../helpers/l10n';
 import { getOrganizationUrl } from '../../../helpers/urls';
+import * as api from '../../../api/organizations';
+import * as actions from '../../../store/organizations';
+import { Store } from '../../../store/rootReducer';
 import '../../../app/styles/sonarcloud.css';
 import '../../tutorials/styles.css'; // TODO remove me
 
 interface Props {
   createOrganization: (organization: OrganizationBase) => Promise<Organization>;
+  deleteOrganization: (key: string) => Promise<void>;
 }
 
 enum Step {
@@ -100,10 +104,9 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   };
 
   handleFreePlanChoose = () => {
-    this.createOrganization().then(
-      key => this.props.router.push(getOrganizationUrl(key)),
-      () => {}
-    );
+    return this.createOrganization().then(key => {
+      this.props.router.push(getOrganizationUrl(key));
+    });
   };
 
   createOrganization = () => {
@@ -123,6 +126,13 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     }
   };
 
+  deleteOrganization = () => {
+    const { organization } = this.state;
+    if (organization) {
+      this.props.deleteOrganization(organization.key).catch(() => {});
+    }
+  };
+
   render() {
     const { location } = this.props;
     const { loading, subscriptionPlans } = this.state;
@@ -171,6 +181,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
                 this.state.organization && (
                   <PlanStep
                     createOrganization={this.createOrganization}
+                    deleteOrganization={this.deleteOrganization}
                     onFreePlanChoose={this.handleFreePlanChoose}
                     onPaidPlanChoose={this.handlePaidPlanChoose}
                     onlyPaid={location.state && location.state.paid === true}
@@ -187,7 +198,27 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   }
 }
 
-const mapDispatchToProps = { createOrganization: createOrganization as any };
+function createOrganization(organization: OrganizationBase) {
+  return (dispatch: Dispatch<Store>) => {
+    return api.createOrganization(organization).then((organization: Organization) => {
+      dispatch(actions.createOrganization(organization));
+      return organization;
+    });
+  };
+}
+
+function deleteOrganization(key: string) {
+  return (dispatch: Dispatch<Store>) => {
+    return api.deleteOrganization(key).then(() => {
+      dispatch(actions.deleteOrganization(key));
+    });
+  };
+}
+
+const mapDispatchToProps = {
+  createOrganization: createOrganization as any,
+  deleteOrganization: deleteOrganization as any
+};
 
 export default whenLoggedIn(
   connect(
index fc7050b604245931ca01b836dbb71648eb047f3b..f715a4f4d48ec7610c57adbe1992c3a6c68384cf 100644 (file)
@@ -27,10 +27,12 @@ import { translate } from '../../../helpers/l10n';
 import { getExtensionStart } from '../../../app/components/extensions/utils';
 import { SubscriptionPlan } from '../../../app/types';
 import { SubmitButton } from '../../../components/ui/buttons';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
 
 interface Props {
   createOrganization: () => Promise<string>;
-  onFreePlanChoose: () => void;
+  deleteOrganization: () => void;
+  onFreePlanChoose: () => Promise<void>;
   onPaidPlanChoose: () => void;
   onlyPaid?: boolean;
   open: boolean;
@@ -42,6 +44,7 @@ interface State {
   paymentMethod?: PaymentMethod;
   plan: Plan;
   ready: boolean;
+  submitting: boolean;
 }
 
 export default class PlanStep extends React.PureComponent<Props, State> {
@@ -51,7 +54,8 @@ export default class PlanStep extends React.PureComponent<Props, State> {
     super(props);
     this.state = {
       plan: props.onlyPaid ? Plan.Paid : Plan.Free,
-      ready: false
+      ready: false,
+      submitting: false
     };
   }
 
@@ -79,6 +83,17 @@ export default class PlanStep extends React.PureComponent<Props, State> {
     this.setState({ paymentMethod });
   };
 
+  stopSubmitting = () => {
+    if (this.mounted) {
+      this.setState({ submitting: false });
+    }
+  };
+
+  handleFreePlanSubmit = () => {
+    this.setState({ submitting: true });
+    this.props.onFreePlanChoose().then(this.stopSubmitting, this.stopSubmitting);
+  };
+
   renderForm = () => {
     return (
       <div className="boxed-group-inner">
@@ -101,6 +116,7 @@ export default class PlanStep extends React.PureComponent<Props, State> {
                 {this.state.paymentMethod === PaymentMethod.Card && (
                   <CardForm
                     createOrganization={this.props.createOrganization}
+                    onFailToUpgrade={this.props.deleteOrganization}
                     onSubmit={this.props.onPaidPlanChoose}
                     subscriptionPlans={this.props.subscriptionPlans}
                   />
@@ -108,14 +124,18 @@ export default class PlanStep extends React.PureComponent<Props, State> {
                 {this.state.paymentMethod === PaymentMethod.Coupon && (
                   <CouponForm
                     createOrganization={this.props.createOrganization}
+                    onFailToUpgrade={this.props.deleteOrganization}
                     onSubmit={this.props.onPaidPlanChoose}
                   />
                 )}
               </>
             ) : (
-              <SubmitButton className="big-spacer-top" onClick={this.props.onFreePlanChoose}>
-                {translate('my_account.create_organization')}
-              </SubmitButton>
+              <div className="display-flex-center big-spacer-top">
+                <SubmitButton disabled={this.state.submitting} onClick={this.handleFreePlanSubmit}>
+                  {translate('my_account.create_organization')}
+                </SubmitButton>
+                {this.state.submitting && <DeferredSpinner className="spacer-left" />}
+              </div>
             )}
           </>
         )}
index be130c5f74ba5ff668fc5fae9b711c29c6f3d778..0d8c015ad02200540fe0309b457beca53e0ac094 100644 (file)
@@ -28,6 +28,7 @@ it('should render', () => {
     <CardForm
       createOrganization={jest.fn()}
       currentUser={{ isLoggedIn: false }}
+      onFailToUpgrade={jest.fn()}
       onSubmit={jest.fn()}
       subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]}
     />
index 4b963011d94f0b55b4160a8b144fbeae08c382c1..110e213413cb68169e3263c6b1712e30b58e3019 100644 (file)
@@ -28,6 +28,7 @@ it('should render', () => {
     <CouponForm
       createOrganization={jest.fn()}
       currentUser={{ isLoggedIn: false }}
+      onFailToUpgrade={jest.fn()}
       onSubmit={jest.fn()}
     />
   );
index 3dce4baf976e134742a530f5eec7a8d46ac949a4..e466c12bb5cb5d1c48152bfa7828211ea54a032b 100644 (file)
@@ -69,3 +69,29 @@ 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 router = mockRouter();
+  const wrapper = shallow(
+    <CreateOrganization
+      createOrganization={createOrganization}
+      deleteOrganization={deleteOrganization}
+      // @ts-ignore avoid passing everything from WithRouterProps
+      location={{}}
+      // @ts-ignore avoid passing everything from WithRouterProps
+      router={router}
+    />
+  );
+  await waitAndUpdate(wrapper);
+
+  wrapper.find('OrganizationDetailsStep').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);
+});
index e1360eebab5cc1465da005ed380e7135d5e4e83e..ff6937f0014395924a1fbf35df5a5bc5ae690ea7 100644 (file)
@@ -29,10 +29,11 @@ jest.mock('../../../../app/components/extensions/utils', () => ({
 }));
 
 it('should render and use free plan', async () => {
-  const onFreePlanChoose = jest.fn();
+  const onFreePlanChoose = jest.fn().mockResolvedValue(undefined);
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn().mockResolvedValue('org')}
+      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
       onFreePlanChoose={onFreePlanChoose}
       onPaidPlanChoose={jest.fn()}
       open={true}
@@ -53,7 +54,8 @@ it('should upgrade using card', async () => {
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn().mockResolvedValue('org')}
-      onFreePlanChoose={jest.fn()}
+      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
+      onFreePlanChoose={jest.fn().mockResolvedValue(undefined)}
       onPaidPlanChoose={onPaidPlanChoose}
       open={true}
       startingPrice="10"
@@ -86,7 +88,8 @@ it('should upgrade using coupon', async () => {
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn().mockResolvedValue('org')}
-      onFreePlanChoose={jest.fn()}
+      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
+      onFreePlanChoose={jest.fn().mockResolvedValue(undefined)}
       onPaidPlanChoose={onPaidPlanChoose}
       open={true}
       startingPrice="10"
@@ -118,7 +121,8 @@ it('should preselect paid plan', async () => {
   const wrapper = shallow(
     <PlanStep
       createOrganization={jest.fn()}
-      onFreePlanChoose={jest.fn()}
+      deleteOrganization={jest.fn().mockResolvedValue(undefined)}
+      onFreePlanChoose={jest.fn().mockResolvedValue(undefined)}
       onPaidPlanChoose={jest.fn()}
       onlyPaid={true}
       open={true}
index 91a6ae106f1770092a7f31fbd6a79042f29e777a..6d8afdea20e36cd66ce6058eb18e07941a34c454 100644 (file)
@@ -12,6 +12,7 @@ exports[`should render 1`] = `
     }
     onClose={[Function]}
     onCommit={[MockFunction]}
+    onFailToUpgrade={[MockFunction]}
     organizationKey={[MockFunction]}
     subscriptionPlans={
       Array [
index b9a3b2a53560c442b3d24b2dd0b0597e8b12980d..b6a054b1c6b30c08cf6b6f650f60abcb01720992 100644 (file)
@@ -13,6 +13,7 @@ exports[`should render 1`] = `
     onClose={[Function]}
     onCommit={[MockFunction]}
     onCouponUpdate={[Function]}
+    onFailToUpgrade={[MockFunction]}
     organizationKey={[MockFunction]}
     skipBraintreeInit={true}
     subscriptionPlans={Array []}
index 7892d99c5301725e9bb264248a6383045407d586..1397ea00a81e2b6153c913e3f1f3d6c0899579ac 100644 (file)
@@ -114,6 +114,7 @@ exports[`should render and create organization 2`] = `
       />
       <PlanStep
         createOrganization={[Function]}
+        deleteOrganization={[Function]}
         onFreePlanChoose={[Function]}
         onPaidPlanChoose={[Function]}
         open={true}
index 28b0d36d4f486e1c1a0e4844adee26ef40e1f32b..216b6f565a3509a4d0a03caac41f321cb63f5651 100644 (file)
@@ -79,12 +79,16 @@ exports[`should render and use free plan 2`] = `
         plan="free"
         startingPrice="10"
       />
-      <SubmitButton
-        className="big-spacer-top"
-        onClick={[MockFunction]}
+      <div
+        className="display-flex-center big-spacer-top"
       >
-        my_account.create_organization
-      </SubmitButton>
+        <SubmitButton
+          disabled={false}
+          onClick={[Function]}
+        >
+          my_account.create_organization
+        </SubmitButton>
+      </div>
     </React.Fragment>
   </div>
 </div>
@@ -157,6 +161,7 @@ exports[`should upgrade using card 2`] = `
         />
         <Connect(withCurrentUser(CardForm))
           createOrganization={[MockFunction]}
+          onFailToUpgrade={[MockFunction]}
           onSubmit={[MockFunction]}
           subscriptionPlans={Array []}
         />
@@ -233,6 +238,7 @@ exports[`should upgrade using coupon 2`] = `
         />
         <Connect(withCurrentUser(CouponForm))
           createOrganization={[MockFunction]}
+          onFailToUpgrade={[MockFunction]}
           onSubmit={[MockFunction]}
         />
       </React.Fragment>