aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2018-11-22 14:30:49 +0100
committerSonarTech <sonartech@sonarsource.com>2018-12-07 20:21:04 +0100
commit1ea65862086353a3bf23d52e9dc7c87effa8a005 (patch)
tree3a1d5ee03a06c1b6123fb0ff9bea2f5e4fa89639
parentfc10db309e0ef2124b2c3c1469bea606642bcf69 (diff)
downloadsonarqube-1ea65862086353a3bf23d52e9dc7c87effa8a005.tar.gz
sonarqube-1ea65862086353a3bf23d52e9dc7c87effa8a005.zip
SONARCLOUD-175 Support step to upgrade organization when importing from ALM
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx225
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx74
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx212
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx105
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx7
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx34
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx188
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx52
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx40
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx91
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx36
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx43
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap108
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap37
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap217
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap56
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap29
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap194
-rw-r--r--server/sonar-web/src/main/js/apps/create/organization/utils.ts5
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties1
21 files changed, 1016 insertions, 744 deletions
diff --git a/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
index 06fa37b4e9d..85627bed7ca 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
@@ -18,37 +18,42 @@
* 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>
);
}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx b/server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
index 875e0b8555b..fb903669336 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
@@ -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}
+ />
+ )}
+ </>
);
}
}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
index 1de0e054c9b..df312aa7804 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
@@ -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;
});
};
}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx b/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
index ea69f4ad3a5..45afee9f63c 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
@@ -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}
/>
)}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx
index a3dbc4e49a9..835d31f912b 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx
@@ -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)) {
diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
index 978b7faa200..acd98cf23ad 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
@@ -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')
+ }
/>
);
}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
index ffb191a0e68..59009a44b98 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
@@ -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>
)}
</>
)}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx b/server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx
index 199fa8899e9..fcf1b9c415f 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx
@@ -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>
);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
index aaed1cd3a24..d4c4ed73bc8 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
@@ -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}
/>
);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
index eeb1de2935d..fbb17c78fe2 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
@@ -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}
/>
);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
index f4471222b68..8e7cbd3ec60 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
@@ -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}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
index 1682a146013..3e7736019ed 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
@@ -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}
/>
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
index 876a20f0bfd..2334b42871f 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
@@ -20,45 +20,46 @@
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);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
index 985f767d6c7..91da2875d70 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
@@ -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>
`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
index b1487a7738b..cece5c58e9a 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
@@ -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>
`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
index 86a76be9310..e69bd5536b6 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
index d538064e72c..c9fab655504 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
@@ -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 {
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
index 80fad1f182b..b03f3c1ef55 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
@@ -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))>
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap
index 92c500b06e7..83c6ba9b429 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/create/organization/utils.ts b/server/sonar-web/src/main/js/apps/create/organization/utils.ts
index 29d09d82fb1..bc2688be093 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/utils.ts
+++ b/server/sonar-web/src/main/js/apps/create/organization/utils.ts
@@ -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$/, '')
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index c4cc99db4ad..5bca60ee0ee 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -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