diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-09-18 17:43:42 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-09-25 20:21:00 +0200 |
commit | abb68832ff18c47f502cd2ab097b5b4b9fc3a509 (patch) | |
tree | dacf53d56390dc2855fa3d8c04ec02c25000beb9 /server/sonar-web/src/main/js | |
parent | c003387eb63a644d9e887dcb7799d962ec27310c (diff) | |
download | sonarqube-abb68832ff18c47f502cd2ab097b5b4b9fc3a509.tar.gz sonarqube-abb68832ff18c47f502cd2ab097b5b4b9fc3a509.zip |
SONARCLOUD-43 Allow users to select the plan when creating an org (#705)
Diffstat (limited to 'server/sonar-web/src/main/js')
48 files changed, 2194 insertions, 210 deletions
diff --git a/server/sonar-web/src/main/js/api/billing.ts b/server/sonar-web/src/main/js/api/billing.ts new file mode 100644 index 00000000000..f0a20dca756 --- /dev/null +++ b/server/sonar-web/src/main/js/api/billing.ts @@ -0,0 +1,29 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { getJSON } from '../helpers/request'; +import throwGlobalError from '../app/utils/throwGlobalError'; +import { SubscriptionPlan } from '../app/types'; + +export function getSubscriptionPlans(): Promise<SubscriptionPlan[]> { + return getJSON('/api/billing/show_subscription_plans').then( + ({ subscriptionPlans }) => subscriptionPlans, + throwGlobalError + ); +} diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index d924a697abf..32ee2c13079 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -37,7 +37,7 @@ export default function GlobalContainer(props: Props) { return ( <SuggestionsProvider> {({ suggestions }) => ( - <StartupModal location={props.location}> + <StartupModal> <div className="global-container"> <div className="page-wrapper" id="container"> <div className="page-container"> diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx index 8481e4dc86f..49863fa9ffc 100644 --- a/server/sonar-web/src/main/js/app/components/StartupModal.tsx +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { withRouter, WithRouterProps } from 'react-router'; import { CurrentUser, isLoggedIn } from '../types'; import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates'; import { EditionKey } from '../../apps/marketplace/utils'; @@ -56,11 +57,10 @@ interface DispatchProps { } interface OwnProps { - location: { pathname: string }; children?: React.ReactNode; } -type Props = StateProps & DispatchProps & OwnProps; +type Props = StateProps & DispatchProps & OwnProps & WithRouterProps; enum ModalKey { license, @@ -77,10 +77,6 @@ interface State { const LICENSE_PROMPT = 'sonarqube.license.prompt'; export class StartupModal extends React.PureComponent<Props, State> { - static contextTypes = { - router: PropTypes.object.isRequired - }; - static childContextTypes = { openProjectOnboarding: PropTypes.func }; @@ -121,13 +117,13 @@ export class StartupModal extends React.PureComponent<Props, State> { openOrganizationOnboarding = () => { this.closeOnboarding(); - this.context.router.push('/create-organization'); + this.props.router.push({ pathname: '/create-organization', state: { paid: true } }); }; openProjectOnboarding = () => { if (isSonarCloud()) { this.setState({ automatic: false, modal: undefined }); - this.context.router.push(`/projects/create`); + this.props.router.push(`/projects/create`); } else { this.setState({ modal: ModalKey.projectOnboarding }); } @@ -212,4 +208,4 @@ const mapDispatchToProps: DispatchProps = { skipOnboardingAction }; export default connect( mapStateToProps, mapDispatchToProps -)(StartupModal); +)(withRouter(StartupModal)); diff --git a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx index 7e076dd9410..62feff5e458 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx @@ -19,11 +19,13 @@ */ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; +import { Location } from 'history'; +import { InjectedRouter } from 'react-router'; import { StartupModal } from '../StartupModal'; import { showLicense } from '../../../api/marketplace'; import { save, get } from '../../../helpers/storage'; import { hasMessage } from '../../../helpers/l10n'; -import { waitAndUpdate } from '../../../helpers/testUtils'; +import { waitAndUpdate, mockRouter } from '../../../helpers/testUtils'; import { differenceInDays, toShortNotSoISOString } from '../../../helpers/dates'; import { LoggedInUser } from '../../types'; import { EditionKey } from '../../../apps/marketplace/utils'; @@ -136,15 +138,16 @@ async function shouldDisplayLicense(wrapper: ShallowWrapper) { function getWrapper(props = {}) { return shallow( + // @ts-ignore avoid passing everything from WithRouterProps <StartupModal canAdmin={true} currentEdition={EditionKey.enterprise} currentUser={LOGGED_IN_USER} - location={{ pathname: 'foo/bar' }} + location={{ pathname: 'foo/bar' } as Location} + router={mockRouter() as InjectedRouter} skipOnboardingAction={jest.fn()} {...props}> <div /> - </StartupModal>, - { context: { router: { push: jest.fn() } } } + </StartupModal> ); } diff --git a/server/sonar-web/src/main/js/app/components/extensions/Extension.js b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx index d2934d30980..7aadebe3108 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/Extension.js +++ b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx @@ -17,36 +17,28 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import Helmet from 'react-helmet'; import * as PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router'; -import { injectIntl } from 'react-intl'; +import { withRouter, WithRouterProps } from 'react-router'; +import { injectIntl, InjectedIntlProps } from 'react-intl'; import { getExtensionStart } from './utils'; import { translate } from '../../../helpers/l10n'; import getStore from '../../utils/getStore'; +import { CurrentUser } from '../../types'; -/*:: -type Props = { - currentUser: Object, - extension: { - key: string, - name: string - }, - intl: Object, - location: { hash: string }, - onFail: string => void, - options?: {}, - router: Object -}; -*/ +interface OwnProps { + currentUser: CurrentUser; + extension: { key: string; name: string }; + onFail: (message: string) => void; + options?: {}; +} + +type Props = OwnProps & WithRouterProps & InjectedIntlProps; -class Extension extends React.PureComponent { - /*:: container: Object; */ - /*:: props: Props; */ - /*:: stop: ?Function; */ +class Extension extends React.PureComponent<Props> { + container?: HTMLElement | null; + stop?: Function; static contextTypes = { suggestions: PropTypes.object.isRequired @@ -56,15 +48,11 @@ class Extension extends React.PureComponent { this.startExtension(); } - componentDidUpdate(prevProps /*: Props */) { + componentDidUpdate(prevProps: Props) { if (prevProps.extension !== this.props.extension) { this.stopExtension(); this.startExtension(); - } else if ( - prevProps.location !== this.props.location && - // old router from backbone app updates hash, don't react in this case - prevProps.location.hash === this.props.location.hash - ) { + } else if (prevProps.location !== this.props.location) { this.startExtension(); } } @@ -73,7 +61,7 @@ class Extension extends React.PureComponent { this.stopExtension(); } - handleStart = (start /*: Function */) => { + handleStart = (start: Function) => { const store = getStore(); this.stop = start({ store, @@ -99,7 +87,7 @@ class Extension extends React.PureComponent { stopExtension() { if (this.stop) { this.stop(); - this.stop = null; + this.stop = undefined; } } @@ -113,4 +101,4 @@ class Extension extends React.PureComponent { } } -export default injectIntl(withRouter(Extension)); +export default injectIntl<OwnProps>(withRouter<OwnProps & InjectedIntlProps>(Extension)); diff --git a/server/sonar-web/src/main/js/app/components/extensions/GlobalAdminPageExtension.js b/server/sonar-web/src/main/js/app/components/extensions/GlobalAdminPageExtension.tsx index 640ef485c36..e479fbc7537 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/GlobalAdminPageExtension.js +++ b/server/sonar-web/src/main/js/app/components/extensions/GlobalAdminPageExtension.tsx @@ -17,26 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import { connect } from 'react-redux'; import ExtensionContainer from './ExtensionContainer'; import NotFound from '../NotFound'; -import { getAppState } from '../../../store/rootReducer'; +import { Extension } from '../../types'; +import { getAppState, Store } from '../../../store/rootReducer'; -/*:: -type Props = { - adminPages: Array<{ key: string }>, - params: { - extensionKey: string, - pluginKey: string - } -}; -*/ +interface Props { + adminPages: Extension[] | undefined; + params: { extensionKey: string; pluginKey: string }; +} -function GlobalAdminPageExtension(props /*: Props */) { +function GlobalAdminPageExtension(props: Props) { const { extensionKey, pluginKey } = props.params; - const extension = props.adminPages.find(p => p.key === `${pluginKey}/${extensionKey}`); + const extension = (props.adminPages || []).find(p => p.key === `${pluginKey}/${extensionKey}`); return extension ? ( <ExtensionContainer extension={extension} /> ) : ( @@ -44,7 +39,7 @@ function GlobalAdminPageExtension(props /*: Props */) { ); } -const mapStateToProps = state => ({ +const mapStateToProps = (state: Store) => ({ adminPages: getAppState(state).adminPages }); diff --git a/server/sonar-web/src/main/js/app/components/extensions/GlobalPageExtension.js b/server/sonar-web/src/main/js/app/components/extensions/GlobalPageExtension.tsx index ede95cb92ed..8d7d535333b 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/GlobalPageExtension.js +++ b/server/sonar-web/src/main/js/app/components/extensions/GlobalPageExtension.tsx @@ -17,26 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import { connect } from 'react-redux'; import ExtensionContainer from './ExtensionContainer'; import NotFound from '../NotFound'; -import { getAppState } from '../../../store/rootReducer'; +import { getAppState, Store } from '../../../store/rootReducer'; +import { Extension } from '../../types'; -/*:: -type Props = { - globalPages: Array<{ key: string }>, - params: { - extensionKey: string, - pluginKey: string - } -}; -*/ +interface Props { + globalPages: Extension[] | undefined; + params: { extensionKey: string; pluginKey: string }; +} -function GlobalPageExtension(props /*: Props */) { +function GlobalPageExtension(props: Props) { const { extensionKey, pluginKey } = props.params; - const extension = props.globalPages.find(p => p.key === `${pluginKey}/${extensionKey}`); + const extension = (props.globalPages || []).find(p => p.key === `${pluginKey}/${extensionKey}`); return extension ? ( <ExtensionContainer extension={extension} /> ) : ( @@ -44,7 +39,7 @@ function GlobalPageExtension(props /*: Props */) { ); } -const mapStateToProps = state => ({ +const mapStateToProps = (state: Store) => ({ globalPages: getAppState(state).globalPages }); diff --git a/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.tsx b/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.tsx index a12e39986cd..d1b1b181a8f 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.tsx @@ -65,7 +65,6 @@ class OrganizationPageExtension extends React.PureComponent<Props> { return extension ? ( <ExtensionContainer extension={extension} - location={this.props.location} options={{ organization, refreshOrganization: this.refreshOrganization }} /> ) : ( diff --git a/server/sonar-web/src/main/js/app/components/extensions/PortfoliosPage.js b/server/sonar-web/src/main/js/app/components/extensions/PortfoliosPage.tsx index 5f34a72964f..182fad8c030 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/PortfoliosPage.js +++ b/server/sonar-web/src/main/js/app/components/extensions/PortfoliosPage.tsx @@ -17,17 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import GlobalPageExtension from './GlobalPageExtension'; -export default function PortfoliosPage(props /*: Object */) { - return ( - <div> - <GlobalPageExtension - location={props.location} - params={{ pluginKey: 'governance', extensionKey: 'portfolios' }} - /> - </div> - ); +export default function PortfoliosPage() { + return <GlobalPageExtension params={{ pluginKey: 'governance', extensionKey: 'portfolios' }} />; } diff --git a/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.js b/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx index e39394c6d91..d4fb48cc14d 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.js +++ b/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx @@ -17,34 +17,26 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import { connect } from 'react-redux'; +import { Location } from 'history'; import ExtensionContainer from './ExtensionContainer'; import NotFound from '../NotFound'; import { addGlobalErrorMessage } from '../../../store/globalMessages'; +import { Component } from '../../types'; -/*:: -type Props = { - component: { - configuration?: { - extensions: Array<{ key: string }> - } - }, - location: { query: { id: string } }, - params: { - extensionKey: string, - pluginKey: string - } -}; -*/ +interface Props { + component: Component; + location: Location; + params: { extensionKey: string; pluginKey: string }; +} -function ProjectAdminPageExtension(props /*: Props */) { +function ProjectAdminPageExtension(props: Props) { const { extensionKey, pluginKey } = props.params; const { component } = props; const extension = component.configuration && - component.configuration.extensions.find(p => p.key === `${pluginKey}/${extensionKey}`); + (component.configuration.extensions || []).find(p => p.key === `${pluginKey}/${extensionKey}`); return extension ? ( <ExtensionContainer extension={extension} options={{ component }} /> ) : ( diff --git a/server/sonar-web/src/main/js/app/components/extensions/utils.js b/server/sonar-web/src/main/js/app/components/extensions/utils.ts index 98600e25581..84c9dccd659 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/utils.js +++ b/server/sonar-web/src/main/js/app/components/extensions/utils.ts @@ -17,21 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow import exposeLibraries from './exposeLibraries'; import { getExtensionFromCache } from '../../utils/installExtensionsHandler'; +import { getBaseUrl } from '../../../helpers/urls'; -function installScript(key /*: string */) { +function installScript(key: string) { return new Promise(resolve => { exposeLibraries(); const scriptTag = document.createElement('script'); - scriptTag.src = `${window.baseUrl}/static/${key}.js`; + scriptTag.src = `${getBaseUrl()}/static/${key}.js`; scriptTag.onload = resolve; document.getElementsByTagName('body')[0].appendChild(scriptTag); }); } -export function getExtensionStart(key /*: string */) { +export function getExtensionStart(key: string): Promise<Function> { return new Promise((resolve, reject) => { const fromCache = getExtensionFromCache(key); if (fromCache) { diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index dcd5aff9e14..b13e0a463a7 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -57,10 +57,6 @@ th.nowrap { font-size: var(--smallFontSize); } -.note a { - color: var(--secondFontColor); -} - .spacer-left { margin-left: 8px !important; } diff --git a/server/sonar-web/src/main/js/app/types.ts b/server/sonar-web/src/main/js/app/types.ts index 4e4153ad236..1ddd5c5e0d5 100644 --- a/server/sonar-web/src/main/js/app/types.ts +++ b/server/sonar-web/src/main/js/app/types.ts @@ -703,6 +703,11 @@ export interface SourceViewerFile { uuid: string; } +export interface SubscriptionPlan { + maxNcloc: number; + price: number; +} + export interface TestCase { coveredLines: number; durationInMs: number; diff --git a/server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx b/server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx new file mode 100644 index 00000000000..8c83ec676b3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx @@ -0,0 +1,57 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { CurrentUser, SubscriptionPlan } from '../../../app/types'; + +interface ChildrenProps { + alertError: string | undefined; + couponValue: string; + onSubmit: React.FormEventHandler; + renderAdditionalInfo: () => React.ReactNode; + renderBillingNameInput: () => React.ReactNode; + renderBraintreeClient: () => React.ReactNode; + renderCountrySelect: () => React.ReactNode; + renderCouponInput: (children?: React.ReactNode) => React.ReactNode; + renderEmailInput: () => React.ReactNode; + renderNextCharge: () => React.ReactNode; + renderPlanSelect: () => React.ReactNode; + renderResetButton: () => React.ReactNode; + renderSpinner: () => React.ReactNode; + renderSubmitButton: (text?: string) => React.ReactNode; + renderTermsOfService: () => React.ReactNode; + renderTypeOfUseSelect: () => React.ReactNode; +} + +interface Props { + children: (props: ChildrenProps) => React.ReactElement<any>; + country?: string; + currentUser: CurrentUser; + onClose: () => void; + onCommit: () => void; + organizationKey: string | (() => Promise<string>); + subscriptionPlans: SubscriptionPlan[]; +} + +export default class BillingFormShim extends React.Component<Props> { + render() { + const { BillingForm } = (window as any).SonarBilling; + return <BillingForm {...this.props} />; + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/CardForm.tsx b/server/sonar-web/src/main/js/apps/create/organization/CardForm.tsx new file mode 100644 index 00000000000..221214668da --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/CardForm.tsx @@ -0,0 +1,89 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import BillingFormShim from './BillingFormShim'; +import { withCurrentUser } from './withCurrentUser'; +import { CurrentUser, SubscriptionPlan } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + createOrganization: () => Promise<string>; + currentUser: CurrentUser; + onSubmit: () => void; + subscriptionPlans: SubscriptionPlan[]; +} + +export class CardForm extends React.PureComponent<Props> { + handleClose = () => { + // do nothing + }; + + render() { + return ( + <div className="huge-spacer-top"> + <BillingFormShim + currentUser={this.props.currentUser} + onClose={this.handleClose} + onCommit={this.props.onSubmit} + organizationKey={this.props.createOrganization} + subscriptionPlans={this.props.subscriptionPlans}> + {form => ( + <form onSubmit={form.onSubmit}> + <div className="columns column-show-overflow"> + <div className="column-half"> + <h3>{translate('billing.upgrade.billing_info')}</h3> + {form.renderEmailInput()} + {form.renderTypeOfUseSelect()} + {form.renderBillingNameInput()} + {form.renderCountrySelect()} + {form.renderAdditionalInfo()} + </div> + <div className="column-half"> + <h3>{translate('billing.upgrade.plan')}</h3> + {form.renderPlanSelect()} + <h3>{translate('billing.upgrade.card_info')}</h3> + {form.renderBraintreeClient()} + </div> + </div> + <div className="upgrade-footer big-spacer-top"> + {form.renderNextCharge()} + <hr className="big-spacer-bottom" /> + {form.alertError && <p className="alert alert-danger">{form.alertError}</p>} + </div> + <div + className={classNames({ + 'big-spacer-top': form.alertError !== undefined + })}> + {form.renderSpinner()} + {form.renderSubmitButton( + translate('onboarding.create_organization.create_and_upgrade') + )} + </div> + {form.renderTermsOfService()} + </form> + )} + </BillingFormShim> + </div> + ); + } +} + +export default withCurrentUser(CardForm); diff --git a/server/sonar-web/src/main/js/apps/create/organization/CouponForm.tsx b/server/sonar-web/src/main/js/apps/create/organization/CouponForm.tsx new file mode 100644 index 00000000000..4747165561d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/CouponForm.tsx @@ -0,0 +1,82 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import BillingFormShim from './BillingFormShim'; +import { withCurrentUser } from './withCurrentUser'; +import { CurrentUser } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; +import DocTooltip from '../../../components/docs/DocTooltip'; + +interface Props { + createOrganization: () => Promise<string>; + currentUser: CurrentUser; + onSubmit: () => void; +} + +export class CouponForm extends React.PureComponent<Props> { + handleClose = () => { + // do nothing + }; + + render() { + return ( + <div className="huge-spacer-top"> + <BillingFormShim + currentUser={this.props.currentUser} + onClose={this.handleClose} + onCommit={this.props.onSubmit} + organizationKey={this.props.createOrganization} + subscriptionPlans={[]}> + {form => ( + <form onSubmit={form.onSubmit}> + <div className="hidden">{form.renderBraintreeClient()}</div> + {form.renderCouponInput( + <label htmlFor="coupon"> + {translate('billing.upgrade.coupon')} + <DocTooltip + className="little-spacer-left" + doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/billing/coupon.md')} + /> + </label> + )} + <h3 className="big-spacer-top">{translate('billing.upgrade.billing_info')}</h3> + {form.renderEmailInput()} + {form.renderTypeOfUseSelect()} + {form.renderBillingNameInput()} + {form.renderCountrySelect()} + {form.renderAdditionalInfo()} + {form.alertError && <p className="alert alert-danger">{form.alertError}</p>} + <div className={classNames({ 'big-spacer-top': form.alertError !== undefined })}> + {form.renderSpinner()} + {form.renderSubmitButton( + translate('onboarding.create_organization.create_and_upgrade') + )} + </div> + {form.renderTermsOfService()} + </form> + )} + </BillingFormShim> + </div> + ); + } +} + +export default withCurrentUser(CouponForm); 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 cf61cd192ed..7837907b045 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 @@ -23,10 +23,13 @@ import { FormattedMessage } from 'react-intl'; import { Link, withRouter, WithRouterProps } from 'react-router'; import { connect } from 'react-redux'; import OrganizationDetailsStep from './OrganizationDetailsStep'; +import PlanStep from './PlanStep'; +import { formatPrice } from './utils'; import { whenLoggedIn } from './whenLoggedIn'; -import { translate } from '../../../helpers/l10n'; -import { OrganizationBase, Organization } from '../../../app/types'; import { createOrganization } from '../../organizations/actions'; +import { getSubscriptionPlans } from '../../../api/billing'; +import { OrganizationBase, Organization, SubscriptionPlan } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; import { getOrganizationUrl } from '../../../helpers/urls'; import '../../../app/styles/sonarcloud.css'; import '../../tutorials/styles.css'; // TODO remove me @@ -35,13 +38,30 @@ interface Props { createOrganization: (organization: OrganizationBase) => Promise<Organization>; } -export class CreateOrganization extends React.PureComponent<Props & WithRouterProps> { +enum Step { + OrganizationDetails, + Plan +} + +interface State { + loading: boolean; + organization?: Organization; + step: Step; + subscriptionPlans?: SubscriptionPlan[]; +} + +export class CreateOrganization extends React.PureComponent<Props & WithRouterProps, State> { mounted = false; + state: State = { + loading: true, + step: Step.OrganizationDetails + }; componentDidMount() { this.mounted = true; document.body.classList.add('white-page'); document.documentElement.classList.add('white-page'); + this.fetchSubscriptionPlans(); } componentWillUnmount() { @@ -49,22 +69,66 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr document.body.classList.remove('white-page'); } - handleOrganizationCreate = (organization: Required<OrganizationBase>) => { - return this.props - .createOrganization({ - avatar: organization.avatar, - description: organization.description, - key: organization.key, - name: organization.name || organization.key, - url: organization.url - }) - .then(organization => { - this.props.router.push(getOrganizationUrl(organization.key)); - }); + fetchSubscriptionPlans = () => { + getSubscriptionPlans().then( + subscriptionPlans => { + if (this.mounted) { + this.setState({ loading: false, subscriptionPlans }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + }; + + handleOrganizationDetailsStepOpen = () => { + this.setState({ step: Step.OrganizationDetails }); + }; + + handleOrganizationDetailsFinish = (organization: Required<OrganizationBase>) => { + this.setState({ organization, step: Step.Plan }); + return Promise.resolve(); + }; + + handlePaidPlanChoose = () => { + if (this.state.organization) { + this.props.router.push(getOrganizationUrl(this.state.organization.key)); + } + }; + + handleFreePlanChoose = () => { + this.createOrganization().then( + key => this.props.router.push(getOrganizationUrl(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 { + return Promise.reject(); + } }; render() { + const { location } = this.props; + const { loading, subscriptionPlans } = this.state; const header = translate('onboarding.create_organization.page.header'); + const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; + const formattedPrice = formatPrice(startedPrice); return ( <> @@ -72,25 +136,51 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr <div className="sonarcloud page page-limited"> <header className="page-header"> <h1 className="page-title big-spacer-bottom">{header}</h1> - <div className="page-actions"> - <Link to="/">{translate('cancel')}</Link> - </div> - <p className="page-description"> - <FormattedMessage - defaultMessage={translate('onboarding.create_organization.page.description')} - id="onboarding.create_organization.page.description" - values={{ - break: <br />, - price: '€10', // TODO - more: ( - <Link to="/documentation/sonarcloud-pricing">{translate('learn_more')}</Link> - ) - }} - /> - </p> + {startedPrice !== undefined && ( + <p className="page-description"> + <FormattedMessage + defaultMessage={translate('onboarding.create_organization.page.description')} + id="onboarding.create_organization.page.description" + values={{ + break: <br />, + price: formattedPrice, + more: ( + <Link target="_blank" to="/documentation/sonarcloud-pricing"> + {translate('learn_more')} + </Link> + ) + }} + /> + </p> + )} </header> - <OrganizationDetailsStep onContinue={this.handleOrganizationCreate} /> + {loading ? ( + <i className="spinner" /> + ) : ( + <> + <OrganizationDetailsStep + finished={this.state.organization !== undefined} + onContinue={this.handleOrganizationDetailsFinish} + onOpen={this.handleOrganizationDetailsStepOpen} + open={this.state.step === Step.OrganizationDetails} + organization={this.state.organization} + /> + + {subscriptionPlans !== undefined && + this.state.organization && ( + <PlanStep + createOrganization={this.createOrganization} + onFreePlanChoose={this.handleFreePlanChoose} + onPaidPlanChoose={this.handlePaidPlanChoose} + onlyPaid={location.state && location.state.paid === true} + open={this.state.step === Step.Plan} + startingPrice={formattedPrice} + subscriptionPlans={subscriptionPlans} + /> + )} + </> + )} </div> </> ); 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 ca40212da76..4c7f28a20a6 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 @@ -23,6 +23,7 @@ import Step from '../../tutorials/components/Step'; import ValidationForm, { ChildrenProps } from '../../../components/controls/ValidationForm'; import { translate } from '../../../helpers/l10n'; import { ResetButtonLink, SubmitButton } from '../../../components/ui/buttons'; +import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon'; import DropdownIcon from '../../../components/icons-components/DropdownIcon'; import { isUrl } from '../../../helpers/urls'; import { OrganizationBase } from '../../../app/types'; @@ -39,7 +40,11 @@ const initialValues: Values = { }; interface Props { + finished: boolean; onContinue: (organization: Required<OrganizationBase>) => Promise<void>; + onOpen: () => void; + open: boolean; + organization?: OrganizationBase & { key: string }; } interface State { @@ -49,6 +54,21 @@ interface State { export default class OrganizationDetailsStep extends React.PureComponent<Props, State> { state: State = { additional: false }; + getInitialValues = (): Values => { + const { organization } = this.props; + if (organization) { + return { + avatar: organization.avatar || '', + description: organization.description || '', + name: organization.name, + key: organization.key, + url: organization.url || '' + }; + } else { + return initialValues; + } + }; + handleAdditionalClick = () => { this.setState(state => ({ additional: !state.additional })); }; @@ -81,6 +101,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props, return Promise.reject(errors); } + // TODO debounce return this.checkFreeKey(key).then(free => { if (!free) { errors.key = translate('onboarding.create_organization.organization_name.taken'); @@ -178,10 +199,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props, </div> </div> <div className="big-spacer-top"> - <SubmitButton disabled={isSubmitting || !isValid || !dirty}> - {/* // TODO change me */} - {translate('onboarding.create_organization.page.header')} - </SubmitButton> + <SubmitButton disabled={isSubmitting || !isValid}>{translate('continue')}</SubmitButton> </div> </> ); @@ -191,7 +209,8 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props, return ( <div className="boxed-group-inner"> <ValidationForm<Values> - initialValues={initialValues} + initialValues={this.getInitialValues()} + isInitialValid={this.props.organization !== undefined} onSubmit={this.props.onContinue} validate={this.handleValidate}> {this.renderInnerForm} @@ -200,14 +219,24 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props, ); }; + renderResult = () => { + const { organization } = this.props; + return organization ? ( + <div className="boxed-group-actions display-flex-center"> + <AlertSuccessIcon className="spacer-right" /> + <strong>{organization.key}</strong> + </div> + ) : null; + }; + render() { return ( <Step - finished={false} - onOpen={() => {}} - open={true} + finished={this.props.finished} + onOpen={this.props.onOpen} + open={this.props.open} renderForm={this.renderForm} - renderResult={() => <div />} + renderResult={this.renderResult} stepNumber={1} stepTitle={translate('onboarding.create_organization.enter_org_details')} /> diff --git a/server/sonar-web/src/main/js/apps/create/organization/PaymentMethodSelect.tsx b/server/sonar-web/src/main/js/apps/create/organization/PaymentMethodSelect.tsx new file mode 100644 index 00000000000..ddf53728c55 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/PaymentMethodSelect.tsx @@ -0,0 +1,57 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import RadioToggle from '../../../components/controls/RadioToggle'; +import { translate } from '../../../helpers/l10n'; + +export enum PaymentMethod { + Card = 'card', + Coupon = 'coupon' +} + +interface Props { + onChange: (paymentMethod: PaymentMethod) => void; + paymentMethod: PaymentMethod | undefined; +} + +export default class PaymentMethodSelect extends React.PureComponent<Props> { + render() { + const options = Object.values(PaymentMethod).map(value => ({ + label: translate('billing', value), + value + })); + + return ( + <div> + <label className="spacer-bottom"> + {translate('onboarding.create_organization.choose_payment_method')} + </label> + <div className="little-spacer-top"> + <RadioToggle + name="payment-method" + onCheck={this.props.onChange} + options={options} + value={this.props.paymentMethod} + /> + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx new file mode 100644 index 00000000000..c5310dcedf8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx @@ -0,0 +1,87 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import Radio from '../../../components/controls/Radio'; +import { translate } from '../../../helpers/l10n'; + +export enum Plan { + Free = 'free', + Paid = 'paid' +} + +interface Props { + onChange: (plan: Plan) => void; + plan: Plan; + startingPrice: string; +} + +export default class PlanSelect extends React.PureComponent<Props> { + handleFreePlanClick = () => { + this.props.onChange(Plan.Free); + }; + + handlePaidPlanClick = () => { + this.props.onChange(Plan.Paid); + }; + + render() { + const { plan } = this.props; + return ( + <div + aria-label={translate('onboarding.create_organization.choose_plan')} + className="huge-spacer-bottom" + role="radiogroup"> + <div> + <Radio checked={plan === Plan.Free} onCheck={this.handleFreePlanClick}> + <span>{translate('billing.free_plan.title')}</span> + </Radio> + <p className="note markdown little-spacer-top"> + {translate('billing.free_plan.description')} + </p> + </div> + <div className="big-spacer-top"> + <Radio checked={plan === Plan.Paid} onCheck={this.handlePaidPlanClick}> + <span>{translate('billing.paid_plan.title')}</span> + </Radio> + <p className="note markdown little-spacer-top"> + <FormattedMessage + defaultMessage={translate('billing.paid_plan.description')} + id="billing.paid_plan.description" + values={{ + price: this.props.startingPrice, + more: ( + <> + {' '} + <Link target="_blank" to="/documentation/sonarcloud-pricing"> + {translate('learn_more')} + </Link> + <br /> + </> + ) + }} + /> + </p> + </div> + </div> + ); + } +} 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 new file mode 100644 index 00000000000..fc7050b6042 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx @@ -0,0 +1,145 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import PaymentMethodSelect, { PaymentMethod } from './PaymentMethodSelect'; +import CardForm from './CardForm'; +import CouponForm from './CouponForm'; +import PlanSelect, { Plan } from './PlanSelect'; +import Step from '../../tutorials/components/Step'; +import { translate } from '../../../helpers/l10n'; +import { getExtensionStart } from '../../../app/components/extensions/utils'; +import { SubscriptionPlan } from '../../../app/types'; +import { SubmitButton } from '../../../components/ui/buttons'; + +interface Props { + createOrganization: () => Promise<string>; + onFreePlanChoose: () => void; + onPaidPlanChoose: () => void; + onlyPaid?: boolean; + open: boolean; + startingPrice: string; + subscriptionPlans: SubscriptionPlan[]; +} + +interface State { + paymentMethod?: PaymentMethod; + plan: Plan; + ready: boolean; +} + +export default class PlanStep extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + this.state = { + plan: props.onlyPaid ? Plan.Paid : Plan.Free, + ready: false + }; + } + + componentDidMount() { + this.mounted = true; + getExtensionStart('billing/billing').then( + () => { + if (this.mounted) { + this.setState({ ready: true }); + } + }, + () => {} + ); + } + + componentWillUnmount() { + this.mounted = false; + } + + handlePlanChange = (plan: Plan) => { + this.setState({ plan }); + }; + + handlePaymentMethodChange = (paymentMethod: PaymentMethod) => { + this.setState({ paymentMethod }); + }; + + renderForm = () => { + return ( + <div className="boxed-group-inner"> + {this.state.ready && ( + <> + {!this.props.onlyPaid && ( + <PlanSelect + onChange={this.handlePlanChange} + plan={this.state.plan} + startingPrice={this.props.startingPrice} + /> + )} + + {this.state.plan === Plan.Paid ? ( + <> + <PaymentMethodSelect + onChange={this.handlePaymentMethodChange} + paymentMethod={this.state.paymentMethod} + /> + {this.state.paymentMethod === PaymentMethod.Card && ( + <CardForm + createOrganization={this.props.createOrganization} + onSubmit={this.props.onPaidPlanChoose} + subscriptionPlans={this.props.subscriptionPlans} + /> + )} + {this.state.paymentMethod === PaymentMethod.Coupon && ( + <CouponForm + createOrganization={this.props.createOrganization} + onSubmit={this.props.onPaidPlanChoose} + /> + )} + </> + ) : ( + <SubmitButton className="big-spacer-top" onClick={this.props.onFreePlanChoose}> + {translate('my_account.create_organization')} + </SubmitButton> + )} + </> + )} + </div> + ); + }; + + render() { + const stepTitle = translate( + this.props.onlyPaid + ? 'onboarding.create_organization.enter_payment_details' + : 'onboarding.create_organization.choose_plan' + ); + + return ( + <Step + finished={false} + onOpen={() => {}} + open={this.props.open} + renderForm={this.renderForm} + renderResult={() => null} + stepNumber={2} + stepTitle={stepTitle} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__mocks__/BillingFormShim.tsx b/server/sonar-web/src/main/js/apps/create/organization/__mocks__/BillingFormShim.tsx new file mode 100644 index 00000000000..4c9b4e3596a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__mocks__/BillingFormShim.tsx @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; + +export default class BillingFormShim extends React.Component<{ children: any }> { + render() { + return ( + <div id="BillingFormShim"> + {this.props.children({ + alertError: undefined, + couponValue: '', + onSubmit: jest.fn(), + renderAdditionalInfo: () => <div id="additional-info" />, + renderBillingNameInput: () => <div id="billing-name" />, + renderBraintreeClient: () => <div id="braintree-client" />, + renderCountrySelect: () => <div id="country-select" />, + renderCouponInput: () => <div id="coupon-input" />, + renderEmailInput: () => <div id="email-input" />, + renderNextCharge: () => <div id="next-charge" />, + renderPlanSelect: () => <div id="plan-select" />, + renderResetButton: () => <div id="reset-button" />, + renderSpinner: () => <div id="spinner" />, + renderSubmitButton: () => <div id="submit-button" />, + renderTermsOfService: () => <div id="terms-of-service" />, + renderTypeOfUseSelect: () => <div id="type-of-use-select" /> + })} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/BillingFormShim-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/BillingFormShim-test.tsx new file mode 100644 index 00000000000..91e2eb8a4ef --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/BillingFormShim-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import BillingFormShim from '../BillingFormShim'; + +beforeAll(() => { + function BillingForm() { + return <div id="billing-form" />; + } + + (window as any).SonarBilling = { BillingForm }; +}); + +afterAll(() => { + delete (window as any).SonarBilling; +}); + +it('should render', () => { + expect( + shallow( + <BillingFormShim + currentUser={{ isLoggedIn: false }} + onClose={jest.fn()} + onCommit={jest.fn()} + organizationKey="org" + subscriptionPlans={[]}> + {() => <div id="inner-billing-form" />} + </BillingFormShim> + ) + ).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CardForm-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CardForm-test.tsx new file mode 100644 index 00000000000..be130c5f74b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CardForm-test.tsx @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { CardForm } from '../CardForm'; + +jest.mock('../BillingFormShim'); + +it('should render', () => { + const wrapper = shallow( + <CardForm + createOrganization={jest.fn()} + currentUser={{ isLoggedIn: false }} + onSubmit={jest.fn()} + subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]} + /> + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('BillingFormShim').dive()).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CouponForm-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CouponForm-test.tsx new file mode 100644 index 00000000000..4b963011d94 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CouponForm-test.tsx @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { CouponForm } from '../CouponForm'; + +jest.mock('../BillingFormShim'); + +it('should render', () => { + const wrapper = shallow( + <CouponForm + createOrganization={jest.fn()} + currentUser={{ isLoggedIn: false }} + onSubmit={jest.fn()} + /> + ); + expect(wrapper).toMatchSnapshot(); +}); 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 09a6cef02ef..3dce4baf976 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 @@ -20,26 +20,52 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { CreateOrganization } from '../CreateOrganization'; -import { mockRouter } from '../../../../helpers/testUtils'; +import { mockRouter, waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/billing', () => ({ + getSubscriptionPlans: jest + .fn() + .mockResolvedValue([{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]) +})); + +const organization = { + avatar: 'http://example.com/avatar', + description: 'description-foo', + key: 'key-foo', + name: 'name-foo', + url: 'http://example.com/foo' +}; it('should render and create organization', async () => { const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' }); const router = mockRouter(); const wrapper = shallow( // @ts-ignore avoid passing everything from WithRouterProps - <CreateOrganization createOrganization={createOrganization} router={router} /> + <CreateOrganization createOrganization={createOrganization} location={{}} router={router} /> ); + await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); - const organization = { - avatar: 'http://example.com/avatar', - description: 'description-foo', - key: 'key-foo', - name: 'name-foo', - url: 'http://example.com/foo' - }; wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization); - await new Promise(setImmediate); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('PlanStep').prop<Function>('onFreePlanChoose')(); + await waitAndUpdate(wrapper); expect(createOrganization).toBeCalledWith(organization); expect(router.push).toBeCalledWith('/organizations/foo'); }); + +it('should preselect paid plan', async () => { + const router = mockRouter(); + const location = { state: { paid: true } }; + const wrapper = shallow( + // @ts-ignore avoid passing everything from WithRouterProps + <CreateOrganization createOrganization={jest.fn()} location={location} router={router} /> + ); + await waitAndUpdate(wrapper); + wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization); + await waitAndUpdate(wrapper); + + expect(wrapper.find('PlanStep').prop('onlyPaid')).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx index 8d6ddf788aa..056856e7c6d 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx @@ -31,8 +31,15 @@ beforeEach(() => { (getOrganization as jest.Mock).mockResolvedValue(undefined); }); -it('should render', () => { - const wrapper = shallow(<OrganizationDetailsStep onContinue={jest.fn()} />); +it('should render form', () => { + const wrapper = shallow( + <OrganizationDetailsStep + finished={false} + onContinue={jest.fn()} + onOpen={jest.fn()} + open={true} + /> + ); expect(wrapper).toMatchSnapshot(); expect(wrapper.dive()).toMatchSnapshot(); expect(getForm(wrapper)).toMatchSnapshot(); @@ -52,7 +59,14 @@ it('should render', () => { }); it('should validate', () => { - const wrapper = shallow(<OrganizationDetailsStep onContinue={jest.fn()} />); + const wrapper = shallow( + <OrganizationDetailsStep + finished={false} + onContinue={jest.fn()} + onOpen={jest.fn()} + open={true} + /> + ); const instance = wrapper.instance() as OrganizationDetailsStep; expect( @@ -91,6 +105,19 @@ it('should validate', () => { ).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.taken' }); }); +it('should render result', () => { + const wrapper = shallow( + <OrganizationDetailsStep + finished={true} + onContinue={jest.fn()} + onOpen={jest.fn()} + open={false} + organization={{ avatar: '', description: '', key: 'org', name: 'Organization', url: '' }} + /> + ); + expect(wrapper.dive()).toMatchSnapshot(); +}); + function getForm(wrapper: ShallowWrapper) { return wrapper .dive() diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PaymentMethodSelect-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PaymentMethodSelect-test.tsx new file mode 100644 index 00000000000..fea97e71637 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PaymentMethodSelect-test.tsx @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import PaymentMethodSelect, { PaymentMethod } from '../PaymentMethodSelect'; + +it('should render and change', () => { + const onChange = jest.fn(); + const wrapper = shallow(<PaymentMethodSelect onChange={onChange} paymentMethod={undefined} />); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('RadioToggle').prop<Function>('onCheck')(PaymentMethod.Card); + expect(onChange).toBeCalledWith(PaymentMethod.Card); + + wrapper.setProps({ paymentMethod: PaymentMethod.Card }); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanSelect-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanSelect-test.tsx new file mode 100644 index 00000000000..ffe6c520eec --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanSelect-test.tsx @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import PlanSelect, { Plan } from '../PlanSelect'; + +it('should render and select', () => { + const onChange = jest.fn(); + const wrapper = shallow(<PlanSelect onChange={onChange} plan={Plan.Free} startingPrice="10" />); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('Radio[checked=false]').prop<Function>('onCheck')(); + expect(onChange).toBeCalledWith(Plan.Paid); + + wrapper.setProps({ plan: Plan.Paid }); + expect(wrapper).toMatchSnapshot(); +}); 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 new file mode 100644 index 00000000000..e1360eebab5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx @@ -0,0 +1,132 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import PlanStep from '../PlanStep'; +import { waitAndUpdate, click } from '../../../../helpers/testUtils'; +import { Plan } from '../PlanSelect'; +import { PaymentMethod } from '../PaymentMethodSelect'; + +jest.mock('../../../../app/components/extensions/utils', () => ({ + getExtensionStart: jest.fn().mockResolvedValue(undefined) +})); + +it('should render and use free plan', async () => { + const onFreePlanChoose = jest.fn(); + const wrapper = shallow( + <PlanStep + createOrganization={jest.fn().mockResolvedValue('org')} + onFreePlanChoose={onFreePlanChoose} + onPaidPlanChoose={jest.fn()} + open={true} + startingPrice="10" + subscriptionPlans={[]} + /> + ); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.dive()).toMatchSnapshot(); + + click(wrapper.dive().find('SubmitButton')); + expect(onFreePlanChoose).toBeCalled(); +}); + +it('should upgrade using card', async () => { + const onPaidPlanChoose = jest.fn(); + const wrapper = shallow( + <PlanStep + createOrganization={jest.fn().mockResolvedValue('org')} + onFreePlanChoose={jest.fn()} + onPaidPlanChoose={onPaidPlanChoose} + open={true} + startingPrice="10" + subscriptionPlans={[]} + /> + ); + await waitAndUpdate(wrapper); + + wrapper + .dive() + .find('PlanSelect') + .prop<Function>('onChange')(Plan.Paid); + expect(wrapper.dive()).toMatchSnapshot(); + + wrapper + .dive() + .find('PaymentMethodSelect') + .prop<Function>('onChange')(PaymentMethod.Card); + expect(wrapper.dive()).toMatchSnapshot(); + + wrapper + .dive() + .find('Connect(withCurrentUser(CardForm))') + .prop<Function>('onSubmit')(); + expect(onPaidPlanChoose).toBeCalled(); +}); + +it('should upgrade using coupon', async () => { + const onPaidPlanChoose = jest.fn(); + const wrapper = shallow( + <PlanStep + createOrganization={jest.fn().mockResolvedValue('org')} + onFreePlanChoose={jest.fn()} + onPaidPlanChoose={onPaidPlanChoose} + open={true} + startingPrice="10" + subscriptionPlans={[]} + /> + ); + await waitAndUpdate(wrapper); + + wrapper + .dive() + .find('PlanSelect') + .prop<Function>('onChange')(Plan.Paid); + expect(wrapper.dive()).toMatchSnapshot(); + + wrapper + .dive() + .find('PaymentMethodSelect') + .prop<Function>('onChange')(PaymentMethod.Coupon); + expect(wrapper.dive()).toMatchSnapshot(); + + wrapper + .dive() + .find('Connect(withCurrentUser(CouponForm))') + .prop<Function>('onSubmit')(); + expect(onPaidPlanChoose).toBeCalled(); +}); + +it('should preselect paid plan', async () => { + const wrapper = shallow( + <PlanStep + createOrganization={jest.fn()} + onFreePlanChoose={jest.fn()} + onPaidPlanChoose={jest.fn()} + onlyPaid={true} + open={true} + startingPrice="10" + subscriptionPlans={[]} + /> + ); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.dive()).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/BillingFormShim-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/BillingFormShim-test.tsx.snap new file mode 100644 index 00000000000..5ed8a8cf920 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/BillingFormShim-test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<BillingForm + currentUser={ + Object { + "isLoggedIn": false, + } + } + onClose={[MockFunction]} + onCommit={[MockFunction]} + organizationKey="org" + subscriptionPlans={Array []} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CardForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CardForm-test.tsx.snap new file mode 100644 index 00000000000..91a6ae106f1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CardForm-test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<div + className="huge-spacer-top" +> + <BillingFormShim + currentUser={ + Object { + "isLoggedIn": false, + } + } + onClose={[Function]} + onCommit={[MockFunction]} + organizationKey={[MockFunction]} + subscriptionPlans={ + Array [ + Object { + "maxNcloc": 100000, + "price": 10, + }, + Object { + "maxNcloc": 250000, + "price": 75, + }, + ] + } + /> +</div> +`; + +exports[`should render 2`] = ` +<div + id="BillingFormShim" +> + <form + onSubmit={[MockFunction]} + > + <div + className="columns column-show-overflow" + > + <div + className="column-half" + > + <h3> + billing.upgrade.billing_info + </h3> + <div + id="email-input" + /> + <div + id="type-of-use-select" + /> + <div + id="billing-name" + /> + <div + id="country-select" + /> + <div + id="additional-info" + /> + </div> + <div + className="column-half" + > + <h3> + billing.upgrade.plan + </h3> + <div + id="plan-select" + /> + <h3> + billing.upgrade.card_info + </h3> + <div + id="braintree-client" + /> + </div> + </div> + <div + className="upgrade-footer big-spacer-top" + > + <div + id="next-charge" + /> + <hr + className="big-spacer-bottom" + /> + </div> + <div + className="" + > + <div + id="spinner" + /> + <div + id="submit-button" + /> + </div> + <div + id="terms-of-service" + /> + </form> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CouponForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CouponForm-test.tsx.snap new file mode 100644 index 00000000000..738f9080adb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CouponForm-test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<div + className="huge-spacer-top" +> + <BillingFormShim + currentUser={ + Object { + "isLoggedIn": false, + } + } + onClose={[Function]} + onCommit={[MockFunction]} + organizationKey={[MockFunction]} + subscriptionPlans={Array []} + /> +</div> +`; 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 96117496248..7892d99c530 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 @@ -19,17 +19,60 @@ exports[`should render and create organization 1`] = ` > onboarding.create_organization.page.header </h1> - <div - className="page-actions" + <p + className="page-description" > - <Link - onlyActiveOnIndex={false} - style={Object {}} - to="/" - > - cancel - </Link> - </div> + <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> + <React.Fragment> + <OrganizationDetailsStep + finished={false} + onContinue={[Function]} + onOpen={[Function]} + open={true} + /> + </React.Fragment> + </div> +</React.Fragment> +`; + +exports[`should render and create organization 2`] = ` +<React.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" > @@ -42,19 +85,53 @@ exports[`should render and create organization 1`] = ` "more": <Link onlyActiveOnIndex={false} style={Object {}} + target="_blank" to="/documentation/sonarcloud-pricing" > learn_more </Link>, - "price": "€10", + "price": "billing.price_format.10", } } /> </p> </header> - <OrganizationDetailsStep - onContinue={[Function]} - /> + <React.Fragment> + <OrganizationDetailsStep + finished={true} + onContinue={[Function]} + onOpen={[Function]} + open={false} + organization={ + Object { + "avatar": "http://example.com/avatar", + "description": "description-foo", + "key": "key-foo", + "name": "name-foo", + "url": "http://example.com/foo", + } + } + /> + <PlanStep + createOrganization={[Function]} + onFreePlanChoose={[Function]} + onPaidPlanChoose={[Function]} + open={true} + startingPrice="billing.price_format.10" + subscriptionPlans={ + Array [ + Object { + "maxNcloc": 100000, + "price": 10, + }, + Object { + "maxNcloc": 250000, + "price": 75, + }, + ] + } + /> + </React.Fragment> </div> </React.Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap index 71347ae70ab..d122f4cc2fb 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render 1`] = ` +exports[`should render form 1`] = ` <Step finished={false} - onOpen={[Function]} + onOpen={[MockFunction]} open={true} renderForm={[Function]} renderResult={[Function]} @@ -12,7 +12,7 @@ exports[`should render 1`] = ` /> `; -exports[`should render 2`] = ` +exports[`should render form 2`] = ` <div className="boxed-group onboarding-step is-open" > @@ -41,6 +41,7 @@ exports[`should render 2`] = ` "url": "", } } + isInitialValid={false} onSubmit={[MockFunction]} validate={[Function]} /> @@ -48,7 +49,7 @@ exports[`should render 2`] = ` </div> `; -exports[`should render 3`] = ` +exports[`should render form 3`] = ` <form onSubmit={[Function]} > @@ -147,9 +148,44 @@ exports[`should render 3`] = ` <SubmitButton disabled={true} > - onboarding.create_organization.page.header + continue </SubmitButton> </div> </React.Fragment> </form> `; + +exports[`should render result 1`] = ` +<div + className="boxed-group onboarding-step is-finished" + onClick={[Function]} + role="button" + tabIndex={0} +> + <div + className="onboarding-step-number" + > + 1 + </div> + <div + className="boxed-group-actions display-flex-center" + > + <AlertSuccessIcon + className="spacer-right" + /> + <strong> + org + </strong> + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.enter_org_details + </h2> + </div> + <div + className="boxed-group-inner" + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PaymentMethodSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PaymentMethodSelect-test.tsx.snap new file mode 100644 index 00000000000..101582f6012 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PaymentMethodSelect-test.tsx.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render and change 1`] = ` +<div> + <label + className="spacer-bottom" + > + onboarding.create_organization.choose_payment_method + </label> + <div + className="little-spacer-top" + > + <RadioToggle + disabled={false} + name="payment-method" + onCheck={[MockFunction]} + options={ + Array [ + Object { + "label": "billing.card", + "value": "card", + }, + Object { + "label": "billing.coupon", + "value": "coupon", + }, + ] + } + value={null} + /> + </div> +</div> +`; + +exports[`should render and change 2`] = ` +<div> + <label + className="spacer-bottom" + > + onboarding.create_organization.choose_payment_method + </label> + <div + className="little-spacer-top" + > + <RadioToggle + disabled={false} + name="payment-method" + onCheck={ + [MockFunction] { + "calls": Array [ + Array [ + "card", + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + } + } + options={ + Array [ + Object { + "label": "billing.card", + "value": "card", + }, + Object { + "label": "billing.coupon", + "value": "coupon", + }, + ] + } + value="card" + /> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanSelect-test.tsx.snap new file mode 100644 index 00000000000..53c6eb02270 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanSelect-test.tsx.snap @@ -0,0 +1,123 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render and select 1`] = ` +<div + aria-label="onboarding.create_organization.choose_plan" + className="huge-spacer-bottom" + role="radiogroup" +> + <div> + <Radio + checked={true} + onCheck={[Function]} + > + <span> + billing.free_plan.title + </span> + </Radio> + <p + className="note markdown little-spacer-top" + > + billing.free_plan.description + </p> + </div> + <div + className="big-spacer-top" + > + <Radio + checked={false} + onCheck={[Function]} + > + <span> + billing.paid_plan.title + </span> + </Radio> + <p + className="note markdown little-spacer-top" + > + <FormattedMessage + defaultMessage="billing.paid_plan.description" + id="billing.paid_plan.description" + values={ + Object { + "more": <React.Fragment> + + <Link + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to="/documentation/sonarcloud-pricing" + > + learn_more + </Link> + <br /> + </React.Fragment>, + "price": "10", + } + } + /> + </p> + </div> +</div> +`; + +exports[`should render and select 2`] = ` +<div + aria-label="onboarding.create_organization.choose_plan" + className="huge-spacer-bottom" + role="radiogroup" +> + <div> + <Radio + checked={false} + onCheck={[Function]} + > + <span> + billing.free_plan.title + </span> + </Radio> + <p + className="note markdown little-spacer-top" + > + billing.free_plan.description + </p> + </div> + <div + className="big-spacer-top" + > + <Radio + checked={true} + onCheck={[Function]} + > + <span> + billing.paid_plan.title + </span> + </Radio> + <p + className="note markdown little-spacer-top" + > + <FormattedMessage + defaultMessage="billing.paid_plan.description" + id="billing.paid_plan.description" + values={ + Object { + "more": <React.Fragment> + + <Link + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to="/documentation/sonarcloud-pricing" + > + learn_more + </Link> + <br /> + </React.Fragment>, + "price": "10", + } + } + /> + </p> + </div> +</div> +`; 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 new file mode 100644 index 00000000000..28b0d36d4f4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap @@ -0,0 +1,242 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should preselect paid plan 1`] = ` +<Step + finished={false} + onOpen={[Function]} + open={true} + renderForm={[Function]} + renderResult={[Function]} + stepNumber={2} + stepTitle="onboarding.create_organization.enter_payment_details" +/> +`; + +exports[`should preselect paid plan 2`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.enter_payment_details + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <React.Fragment> + <PaymentMethodSelect + onChange={[Function]} + /> + </React.Fragment> + </React.Fragment> + </div> +</div> +`; + +exports[`should render and use free plan 1`] = ` +<Step + finished={false} + onOpen={[Function]} + open={true} + renderForm={[Function]} + renderResult={[Function]} + stepNumber={2} + stepTitle="onboarding.create_organization.choose_plan" +/> +`; + +exports[`should render and use free plan 2`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.choose_plan + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <PlanSelect + onChange={[Function]} + plan="free" + startingPrice="10" + /> + <SubmitButton + className="big-spacer-top" + onClick={[MockFunction]} + > + my_account.create_organization + </SubmitButton> + </React.Fragment> + </div> +</div> +`; + +exports[`should upgrade using card 1`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.choose_plan + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <PlanSelect + onChange={[Function]} + plan="paid" + startingPrice="10" + /> + <React.Fragment> + <PaymentMethodSelect + onChange={[Function]} + /> + </React.Fragment> + </React.Fragment> + </div> +</div> +`; + +exports[`should upgrade using card 2`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.choose_plan + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <PlanSelect + onChange={[Function]} + plan="paid" + startingPrice="10" + /> + <React.Fragment> + <PaymentMethodSelect + onChange={[Function]} + paymentMethod="card" + /> + <Connect(withCurrentUser(CardForm)) + createOrganization={[MockFunction]} + onSubmit={[MockFunction]} + subscriptionPlans={Array []} + /> + </React.Fragment> + </React.Fragment> + </div> +</div> +`; + +exports[`should upgrade using coupon 1`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.choose_plan + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <PlanSelect + onChange={[Function]} + plan="paid" + startingPrice="10" + /> + <React.Fragment> + <PaymentMethodSelect + onChange={[Function]} + /> + </React.Fragment> + </React.Fragment> + </div> +</div> +`; + +exports[`should upgrade using coupon 2`] = ` +<div + className="boxed-group onboarding-step is-open" +> + <div + className="onboarding-step-number" + > + 2 + </div> + <div + className="boxed-group-header" + > + <h2> + onboarding.create_organization.choose_plan + </h2> + </div> + <div + className="boxed-group-inner" + > + <React.Fragment> + <PlanSelect + onChange={[Function]} + plan="paid" + startingPrice="10" + /> + <React.Fragment> + <PaymentMethodSelect + onChange={[Function]} + paymentMethod="coupon" + /> + <Connect(withCurrentUser(CouponForm)) + createOrganization={[MockFunction]} + onSubmit={[MockFunction]} + /> + </React.Fragment> + </React.Fragment> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx index c3c1fdeab24..4fc1ee27506 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx @@ -49,5 +49,6 @@ function getRenderedType(wrapper: ShallowWrapper) { return wrapper .dive() .dive() + .dive() .type(); } diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/withCurrentUser-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/withCurrentUser-test.tsx new file mode 100644 index 00000000000..142f6e94f67 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/withCurrentUser-test.tsx @@ -0,0 +1,40 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { createStore } from 'redux'; +import { withCurrentUser } from '../withCurrentUser'; +import { CurrentUser } from '../../../../app/types'; + +class X extends React.Component<{ currentUser: CurrentUser }> { + render() { + return <div />; + } +} + +const UnderTest = withCurrentUser(X); + +it('should pass logged in user', () => { + const currentUser = { isLoggedIn: false }; + const store = createStore(state => state, { users: { currentUser } }); + const wrapper = shallow(<UnderTest />, { context: { store } }); + expect(wrapper.dive().type()).toBe(X); + expect(wrapper.dive().prop('currentUser')).toBe(currentUser); +}); 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 new file mode 100644 index 00000000000..29fc906c0d7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/utils.ts @@ -0,0 +1,28 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { translateWithParameters } from '../../../helpers/l10n'; +import { formatMeasure } from '../../../helpers/measures'; + +export function formatPrice(price?: number, noSign?: boolean) { + const priceFormatted = formatMeasure(price, 'FLOAT') + .replace(/[.|,]0$/, '') + .replace(/([.|,]\d)$/, '$10'); + return noSign ? priceFormatted : translateWithParameters('billing.price_format', priceFormatted); +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx b/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx index 1535c852d2a..ae6e431535d 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx @@ -18,14 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { connect } from 'react-redux'; import { withRouter, WithRouterProps } from 'react-router'; +import { withCurrentUser } from './withCurrentUser'; import { CurrentUser, isLoggedIn } from '../../../app/types'; -import { Store, getCurrentUser } from '../../../store/rootReducer'; export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { + const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + class Wrapper extends React.Component<P & { currentUser: CurrentUser } & WithRouterProps> { - static displayName = `whenLoggedIn(${WrappedComponent.displayName})`; + static displayName = `whenLoggedIn(${wrappedDisplayName})`; componentDidMount() { if (!isLoggedIn(this.props.currentUser)) { @@ -46,9 +47,5 @@ export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { } } - function mapStateToProps(state: Store) { - return { currentUser: getCurrentUser(state) }; - } - - return connect(mapStateToProps)(withRouter(Wrapper)); + return withCurrentUser(withRouter(Wrapper)); } diff --git a/server/sonar-web/src/main/js/apps/create/organization/withCurrentUser.tsx b/server/sonar-web/src/main/js/apps/create/organization/withCurrentUser.tsx new file mode 100644 index 00000000000..117af6607dc --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/withCurrentUser.tsx @@ -0,0 +1,43 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { CurrentUser } from '../../../app/types'; +import { Store, getCurrentUser } from '../../../store/rootReducer'; + +export function withCurrentUser<P>( + WrappedComponent: React.ComponentClass<P & { currentUser: CurrentUser }> +) { + const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + class Wrapper extends React.Component<P & { currentUser: CurrentUser }> { + static displayName = `withCurrentUser(${wrappedDisplayName})`; + + render() { + return <WrappedComponent {...this.props} />; + } + } + + function mapStateToProps(state: Store) { + return { currentUser: getCurrentUser(state) }; + } + + return connect(mapStateToProps)(Wrapper); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx index baca2d16467..039574a03fb 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { InjectedRouter } from 'react-router'; import OnboardingModal from './OnboardingModal'; import { skipOnboarding } from '../../../api/users'; import { skipOnboarding as skipOnboardingAction } from '../../../store/users'; @@ -30,6 +31,10 @@ interface DispatchProps { skipOnboardingAction: () => void; } +interface OwnProps { + router: InjectedRouter; +} + enum ModalKey { onboarding, teamOnboarding @@ -39,10 +44,9 @@ interface State { modal?: ModalKey; } -export class OnboardingPage extends React.PureComponent<DispatchProps, State> { +export class OnboardingPage extends React.PureComponent<OwnProps & DispatchProps, State> { static contextTypes = { - openProjectOnboarding: PropTypes.func.isRequired, - router: PropTypes.object.isRequired + openProjectOnboarding: PropTypes.func.isRequired }; state: State = { modal: ModalKey.onboarding }; @@ -50,16 +54,16 @@ export class OnboardingPage extends React.PureComponent<DispatchProps, State> { closeOnboarding = () => { skipOnboarding(); this.props.skipOnboardingAction(); - this.context.router.replace('/'); + this.props.router.replace('/'); }; closeOrganizationOnboarding = ({ key }: Pick<Organization, 'key'>) => { this.closeOnboarding(); - this.context.router.push(`/organizations/${key}`); + this.props.router.push(`/organizations/${key}`); }; openOrganizationOnboarding = () => { - this.context.router.push('/create-organizations'); + this.props.router.push({ pathname: '/create-organization', state: { paid: true } }); }; openTeamOnboarding = () => { diff --git a/server/sonar-web/src/main/js/components/controls/Radio.tsx b/server/sonar-web/src/main/js/components/controls/Radio.tsx new file mode 100644 index 00000000000..9209ad32c68 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/Radio.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; + +interface Props { + checked: boolean; + className?: string; + onCheck: () => void; +} + +export default class Radio extends React.PureComponent<Props> { + handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { + event.preventDefault(); + event.currentTarget.blur(); + this.props.onCheck(); + }; + + render() { + return ( + <a + aria-checked={this.props.checked} + className={classNames( + 'display-inline-flex-center link-base-color link-no-underline', + this.props.className + )} + href="#" + onClick={this.handleClick} + role="radio"> + <i + className={classNames('icon-radio', 'spacer-right', { + 'is-checked': this.props.checked + })} + /> + {this.props.children} + </a> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx b/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx index 5f33868b67b..b4e061b3924 100644 --- a/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx +++ b/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx @@ -32,20 +32,28 @@ interface Props<V> { } export default class ValidationForm<V> extends React.Component<Props<V>> { + mounted = false; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + handleSubmit = (data: V, { setSubmitting }: FormikActions<V>) => { const result = this.props.onSubmit(data); + const stopSubmitting = () => { + if (this.mounted) { + setSubmitting(false); + } + }; if (result) { - result.then( - () => { - setSubmitting(false); - }, - () => { - setSubmitting(false); - } - ); + result.then(stopSubmitting, stopSubmitting); } else { - setSubmitting(false); + stopSubmitting(); } }; diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx new file mode 100644 index 00000000000..8d4a4f79aa8 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import Radio from '../Radio'; +import { click } from '../../../helpers/testUtils'; + +it('should render and check', () => { + const onCheck = jest.fn(); + const wrapper = shallow(<Radio checked={false} onCheck={onCheck} />); + expect(wrapper).toMatchSnapshot(); + + click(wrapper); + expect(onCheck).toBeCalled(); + wrapper.setProps({ checked: true }); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap new file mode 100644 index 00000000000..92e8076ce5e --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render and check 1`] = ` +<a + aria-checked={false} + className="display-inline-flex-center link-base-color link-no-underline" + href="#" + onClick={[Function]} + role="radio" +> + <i + className="icon-radio spacer-right" + /> +</a> +`; + +exports[`should render and check 2`] = ` +<a + aria-checked={true} + className="display-inline-flex-center link-base-color link-no-underline" + href="#" + onClick={[Function]} + role="radio" +> + <i + className="icon-radio spacer-right is-checked" + /> +</a> +`; |