diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-11-22 14:30:49 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-12-07 20:21:04 +0100 |
commit | 1ea65862086353a3bf23d52e9dc7c87effa8a005 (patch) | |
tree | 3a1d5ee03a06c1b6123fb0ff9bea2f5e4fa89639 | |
parent | fc10db309e0ef2124b2c3c1469bea606642bcf69 (diff) | |
download | sonarqube-1ea65862086353a3bf23d52e9dc7c87effa8a005.tar.gz sonarqube-1ea65862086353a3bf23d52e9dc7c87effa8a005.zip |
SONARCLOUD-175 Support step to upgrade organization when importing from ALM
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 |