Переглянути джерело

SONARCLOUD-175 Support step to upgrade organization when importing from ALM

tags/7.5
Grégoire Aubert 5 роки тому
джерело
коміт
1ea6586208
21 змінених файлів з 1016 додано та 744 видалено
  1. 113
    112
      server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
  2. 48
    26
      server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
  3. 138
    74
      server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
  4. 23
    82
      server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
  5. 3
    3
      server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsForm.tsx
  6. 5
    2
      server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
  7. 21
    13
      server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
  8. 98
    90
      server/sonar-web/src/main/js/apps/create/organization/RemoteOrganizationChoose.tsx
  9. 20
    32
      server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
  10. 24
    16
      server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
  11. 60
    31
      server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
  12. 11
    25
      server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
  13. 21
    22
      server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
  14. 54
    54
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
  15. 28
    9
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
  16. 186
    31
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
  17. 27
    29
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
  18. 22
    7
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
  19. 108
    86
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/RemoteOrganizationChoose-test.tsx.snap
  20. 5
    0
      server/sonar-web/src/main/js/apps/create/organization/utils.ts
  21. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 113
- 112
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>
);
}

+ 48
- 26
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}
/>
)}
</>
);
}
}

+ 138
- 74
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;
});
};
}

+ 23
- 82
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}
/>
)}

+ 3
- 3
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)) {

+ 5
- 2
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')
}
/>
);
}

+ 21
- 13
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>
)}
</>
)}

+ 98
- 90
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>
);

+ 20
- 32
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}
/>
);

+ 24
- 16
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}
/>
);

+ 60
- 31
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}

+ 11
- 25
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}
/>

+ 21
- 22
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);

+ 54
- 54
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>
`;

+ 28
- 9
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>
`;

+ 186
- 31
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>

+ 27
- 29
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 {

+ 22
- 7
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))>

+ 108
- 86
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>

+ 5
- 0
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$/, '')

+ 1
- 0
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

Завантаження…
Відмінити
Зберегти