diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-11-27 14:13:26 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-12-07 20:21:05 +0100 |
commit | 8dea56b4c70d09fc069add79ab0617bf6bb0e16a (patch) | |
tree | 5c49ebbfdb3a82d09682c33fd27bce4f04e1f00c | |
parent | 5aedc697b7bba9e80a97ab86913b61ce662563a8 (diff) | |
download | sonarqube-8dea56b4c70d09fc069add79ab0617bf6bb0e16a.tar.gz sonarqube-8dea56b4c70d09fc069add79ab0617bf6bb0e16a.zip |
SONARCLOUD-176 Allow upgrading to paid organization when provisioning projects
* Update UpgradeOrganizationBox component with new cardPlan component
* Display upgrade box only when needed
* Introduce isDefined type guard
* Update repositories selection workflow
81 files changed, 1900 insertions, 803 deletions
diff --git a/server/sonar-web/src/main/js/api/plugins.ts b/server/sonar-web/src/main/js/api/plugins.ts index faf104eba0b..c1ed5678bc8 100644 --- a/server/sonar-web/src/main/js/api/plugins.ts +++ b/server/sonar-web/src/main/js/api/plugins.ts @@ -20,6 +20,7 @@ import { findLastIndex } from 'lodash'; import { getJSON, post } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; +import { isDefined } from '../helpers/types'; export interface Plugin { key: string; @@ -94,7 +95,7 @@ function getLastUpdates(updates: undefined | Update[]): Update[] { return index > -1 ? updates[index] : undefined; } ); - return lastUpdate.filter(Boolean) as Update[]; + return lastUpdate.filter(isDefined); } function addChangelog(update: Update, updates?: Update[]) { diff --git a/server/sonar-web/src/main/js/app/styles/components/modals.css b/server/sonar-web/src/main/js/app/styles/components/modals.css index 1a4274b1451..20d02df96f9 100644 --- a/server/sonar-web/src/main/js/app/styles/components/modals.css +++ b/server/sonar-web/src/main/js/app/styles/components/modals.css @@ -72,6 +72,10 @@ opacity: 1; } +.modal-no-backdrop { + background-color: transparent; +} + .modal-open, .ReactModal__Body--open { overflow: hidden; @@ -101,22 +105,45 @@ padding: 10px; } +.modal-simple { + border-radius: 3px; +} + .modal-simple-head { - padding: calc(2 * var(--pagePadding)) calc(3 * var(--pagePadding)); + padding: var(--pagePadding) calc(2 * var(--pagePadding)); } .modal-simple-head h1 { + margin-top: var(--pagePadding); font-size: var(--hugeFontSize); font-weight: bold; line-height: 30px; } +.modal-simple-head h2 { + font-size: var(--bigFontSize); + font-weight: bold; + line-height: 24px; +} + .modal-simple-body { - padding: 0 calc(3 * var(--pagePadding)); + padding: 0 calc(2 * var(--pagePadding)) var(--pagePadding); } -.modal-simple-footer { - padding: calc(2 * var(--pagePadding)) calc(3 * var(--pagePadding)); +.modal-simple-foot { + padding: calc(2 * var(--pagePadding)) calc(2 * var(--pagePadding)); + border-radius: 3px; +} + +.modal-simple-foot-action { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--pagePadding) calc(2 * var(--pagePadding)); + border-top: 1px solid var(--barBorderColor); + background-color: var(--barBackgroundColor); + text-align: right; + border-radius: 3px; } .modal-field, 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 0e080164b07..e8cc9b5fa24 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 @@ -101,6 +101,10 @@ th.nowrap { margin-left: 40px !important; } +.huge-spacer-right { + margin-right: 40px !important; +} + .little-spacer-left { margin-left: 4px !important; } @@ -299,6 +303,16 @@ td.big-spacer-top { align-items: center; } +.display-flex-space-around { + display: flex !important; + justify-content: space-around; +} + +.display-flex-space-between { + display: flex !important; + justify-content: space-between; +} + .display-flex-stretch { display: flex !important; align-items: stretch; diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js index 82a207470a7..1a22613180b 100644 --- a/server/sonar-web/src/main/js/app/theme.js +++ b/server/sonar-web/src/main/js/app/theme.js @@ -90,6 +90,7 @@ module.exports = { bigFontSize: '16px', hugeFontSize: '24px', + largeControlHeight: `${4 * grid}px`, controlHeight: `${3 * grid}px`, smallControlHeight: `${2.5 * grid}px`, tinyControlHeight: `${2 * grid}px`, @@ -137,6 +138,7 @@ module.exports = { // sonarcloud sonarcloudOrange500: '#fd6a00', sonarcloudOrange700: '#db5700', + sonarcloudBlack100: '#ffffff', sonarcloudBlack200: '#f9f9fb', sonarcloudBlack250: '#e6e8ea', @@ -145,6 +147,11 @@ module.exports = { sonarcloudBlack700: '#434447', sonarcloudBlack800: '#2d3032', sonarcloudBlack900: '#070706', + + sonarcloudBlue500: '#4c9bd6', + sonarcloudBlue600: '#327bb3', + sonarcloudBlue900: '#0b3c62', + sonarcloudBorderGray: 'rgba(207, 211, 215, 0.5)', sonarcloudFontFamily: "Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif" diff --git a/server/sonar-web/src/main/js/apps/code/components/Components.tsx b/server/sonar-web/src/main/js/apps/code/components/Components.tsx index c8672d01b7e..03331dadc14 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Components.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Components.tsx @@ -22,6 +22,7 @@ import * as classNames from 'classnames'; import Component from './Component'; import ComponentsEmpty from './ComponentsEmpty'; import ComponentsHeader from './ComponentsHeader'; +import { isDefined } from '../../../helpers/types'; import { getCodeMetrics, showLeakMeasure } from '../utils'; interface Props { @@ -36,7 +37,7 @@ interface Props { export default function Components(props: Props) { const { baseComponent, branchLike, components, rootComponent, selected } = props; const metricKeys = getCodeMetrics(rootComponent.qualifier, branchLike); - const metrics = metricKeys.map(metric => props.metrics[metric]).filter(Boolean); + const metrics = metricKeys.map(metric => props.metrics[metric]).filter(isDefined); const isLeak = Boolean(baseComponent && showLeakMeasure(branchLike)); return ( <table className="data boxed-padding zebra"> diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx index c52981c4872..7ed3d2a44b0 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx @@ -19,7 +19,7 @@ */ import * as React from 'react'; import EmptyResult from './EmptyResult'; -import OriginalBubbleChart, { BubbleItem } from '../../../components/charts/BubbleChart'; +import OriginalBubbleChart from '../../../components/charts/BubbleChart'; import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend'; import HelpTooltip from '../../../components/controls/HelpTooltip'; import { formatMeasure, isDiffMetric } from '../../../helpers/measures'; @@ -31,6 +31,7 @@ import { } from '../../../helpers/l10n'; import { getBubbleMetrics, getBubbleYDomain, isProjectOverview } from '../utils'; import { RATING_COLORS } from '../../../helpers/constants'; +import { isDefined } from '../../../helpers/types'; const HEIGHT = 500; @@ -113,13 +114,13 @@ export default class BubbleChart extends React.PureComponent<Props> { size, color: colors !== undefined - ? RATING_COLORS[Math.max(...colors.filter(Boolean) as number[]) - 1] + ? RATING_COLORS[Math.max(...colors.filter(isDefined)) - 1] : undefined, data: component, tooltip: this.getTooltip(component.name, { x, y, size, colors }, metrics) }; }) - .filter(Boolean) as BubbleItem<T.ComponentMeasureEnhanced>[]; + .filter(isDefined); const formatXTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.x.type); const formatYTick = (tick: string | number | undefined) => formatMeasure(tick, metrics.y.type); diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.tsx index 2f07bb3545a..a86c27e135f 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.tsx @@ -30,6 +30,7 @@ import TreeMap, { TreeMapItem } from '../../../components/charts/TreeMap'; import { translate, translateWithParameters, getLocalizedMetricName } from '../../../helpers/l10n'; import { formatMeasure, isDiffMetric } from '../../../helpers/measures'; import { getBranchLikeUrl } from '../../../helpers/urls'; +import { isDefined } from '../../../helpers/types'; interface Props { branchLike?: T.BranchLike; @@ -96,7 +97,7 @@ export default class TreeMapView extends React.PureComponent<Props, State> { }) }; }) - .filter(Boolean) as TreeMapItem[]; + .filter(isDefined); }; getLevelColorScale = () => diff --git a/server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx b/server/sonar-web/src/main/js/apps/create/components/BillingFormShim.tsx index ce44586f84a..c0efc0882ff 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/BillingFormShim.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/BillingFormShim.tsx @@ -21,12 +21,16 @@ import * as React from 'react'; interface ChildrenProps { onSubmit: React.FormEventHandler; - renderFormFields: () => React.ReactElement<any>; - renderSubmitGroup: (submitText?: string) => React.ReactElement<any>; + processingUpgrade: boolean; + renderFormFields: () => React.ReactNode; + renderNextCharge: () => React.ReactNode; + renderRecap: () => React.ReactNode; + renderSubmitButton: (submitText?: string) => React.ReactNode; + renderSubmitGroup: (submitText?: string) => React.ReactNode; } interface Props { - children: (props: ChildrenProps) => React.ReactElement<any>; + children: (props: ChildrenProps) => React.ReactNode; initialCountry?: string; currentUser: T.CurrentUser; onCommit: () => void; diff --git a/server/sonar-web/src/main/js/apps/create/components/CardPlan.css b/server/sonar-web/src/main/js/apps/create/components/CardPlan.css index 7b9f04e90c1..02d7ddb056d 100644 --- a/server/sonar-web/src/main/js/apps/create/components/CardPlan.css +++ b/server/sonar-web/src/main/js/apps/create/components/CardPlan.css @@ -28,19 +28,23 @@ border-radius: 3px; box-sizing: border-box; margin-right: calc(2 * var(--gridSize)); + transition: all 0.2s ease; +} + +.card-plan.highlight { + box-shadow: var(--defaultShadow); } .card-plan:last-child { margin-right: 0; } -.card-plan-actionable { - cursor: pointer; - transition: all 0.2s ease; +.card-plan:focus { + outline: none; } -.card-plan-actionable:focus { - outline: none; +.card-plan-actionable { + cursor: pointer; } .card-plan-actionable:not(.disabled):hover { @@ -52,6 +56,11 @@ border-color: var(--darkBlue); } +.card-plan-actionable.selected .card-plan-recommended { + border: solid 1px var(--darkBlue); + border-top: none; +} + .card-plan-actionable.disabled { cursor: not-allowed; background-color: var(--disableGrayBg); @@ -82,10 +91,6 @@ padding: 0 calc(2 * var(--gridSize)) calc(2 * var(--gridSize)); } -.card-plan-body ul > li { - margin-bottom: calc(var(--gridSize) / 2); -} - .card-plan-body .alert { margin-bottom: 0; } @@ -97,7 +102,7 @@ bottom: -1px; width: 450px; color: #fff; - background-color: var(--darkBlue); + background-color: var(--blue); border-radius: 0 0 3px 3px; box-sizing: border-box; font-size: var(--smallFontSize); diff --git a/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx index 372e64bc526..a2970d28577 100644 --- a/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx @@ -21,12 +21,11 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router'; -import CheckIcon from '../../../components/icons-components/CheckIcon'; +import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; import RecommendedIcon from '../../../components/icons-components/RecommendedIcon'; import { Alert } from '../../../components/ui/Alert'; -import { formatPrice, TRIAL_DURATION_DAYS } from '../organization/utils'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import * as theme from '../../../app/theme'; +import { formatPrice } from '../organization/utils'; +import { translate } from '../../../helpers/l10n'; import './CardPlan.css'; interface Props { @@ -34,7 +33,7 @@ interface Props { disabled?: boolean; onClick?: () => void; selected?: boolean; - startingPrice?: string; + startingPrice?: number; } interface CardProps extends Props { @@ -64,17 +63,18 @@ export default function CardPlan(props: CardProps) { )} {props.title} </span> - {startingPrice ? ( - <FormattedMessage - defaultMessage={translate('billing.price_from_x')} - id="billing.price_from_x" - values={{ - price: <span className="card-plan-price">{startingPrice}</span> - }} - /> - ) : ( - <span className="card-plan-price">{formatPrice(0)}</span> - )} + {startingPrice !== undefined && + (startingPrice ? ( + <FormattedMessage + defaultMessage={translate('billing.price_from_x')} + id="billing.price_from_x" + values={{ + price: <span className="card-plan-price">{formatPrice(startingPrice)}</span> + }} + /> + ) : ( + <span className="card-plan-price">{formatPrice(0)}</span> + ))} </h2> <div className="card-plan-body">{props.children}</div> {recommended && ( @@ -101,12 +101,16 @@ export function FreeCardPlan({ almName, hasWarning, ...props }: FreeProps) { const showWarning = almName && hasWarning && !props.disabled; return ( - <CardPlan title={translate('billing.free_plan.title')} {...props}> + <CardPlan startingPrice={0} title={translate('billing.free_plan.title')} {...props}> <> - <ul className="note"> - <li>{translate('billing.free_plan.all_projects_analyzed_public')}</li> - <li>{translate('billing.free_plan.anyone_can_browse_source_code')}</li> - </ul> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + <li className="little-spacer-bottom"> + {translate('billing.free_plan.all_projects_analyzed_public')} + </li> + <li>{translate('billing.free_plan.anyone_can_browse_source_code')}</li> + </ul> + </div> {showWarning && ( <Alert variant="warning"> <FormattedMessage @@ -135,31 +139,15 @@ interface PaidProps extends Props { } export function PaidCardPlan({ isRecommended, ...props }: PaidProps) { - const advantages = [ - translate('billing.upgrade_box.unlimited_private_projects'), - translate('billing.upgrade_box.strict_control_private_data'), - translate('billing.upgrade_box.cancel_anytime'), - <strong key="trial"> - {translateWithParameters('billing.upgrade_box.free_trial_x', TRIAL_DURATION_DAYS)} - </strong> - ]; - return ( <CardPlan recommended={isRecommended ? translate('billing.paid_plan.recommended') : undefined} title={translate('billing.paid_plan.title')} {...props}> <> - <ul className="note"> - {advantages.map((text, idx) => ( - <li className="display-flex-center" key={idx}> - <CheckIcon className="spacer-right" fill={theme.lightGreen} /> - {text} - </li> - ))} - </ul> + <UpgradeOrganizationAdvantages /> <div className="big-spacer-left"> - <Link className="spacer-left" target="_blank" to="/documentation/sonarcloud-pricing/"> + <Link className="spacer-left" target="_blank" to="/about/pricing"> {translate('billing.pricing.learn_more')} </Link> </div> diff --git a/server/sonar-web/src/main/js/apps/create/components/OrganizationSelect.tsx b/server/sonar-web/src/main/js/apps/create/components/OrganizationSelect.tsx index 84751b58556..7e424b54a56 100644 --- a/server/sonar-web/src/main/js/apps/create/components/OrganizationSelect.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/OrganizationSelect.tsx @@ -62,19 +62,25 @@ export function getOptionRenderer(hideIcons?: boolean) { const icon = organization.alm ? `sonarcloud/${sanitizeAlmId(organization.alm.key)}` : 'sonarcloud-square-logo'; + const isPaidOrg = organization.subscription === 'PAID'; return ( - <span> - {!hideIcons && ( - <img - alt={organization.alm ? organization.alm.key : 'SonarCloud'} - className="spacer-right" - height={14} - src={`${getBaseUrl()}/images/${icon}.svg`} - /> + <div className="display-flex-space-between"> + <span className="text-ellipsis flex-1"> + {!hideIcons && ( + <img + alt={organization.alm ? organization.alm.key : 'SonarCloud'} + className="little-spacer-right" + height={14} + src={`${getBaseUrl()}/images/${icon}.svg`} + /> + )} + {organization.name} + <span className="note little-spacer-left">{organization.key}</span> + </span> + {isPaidOrg && ( + <div className="outline-badge">{translate('organization.paid_plan.badge')}</div> )} - {organization.name} - <span className="note little-spacer-left">{organization.key}</span> - </span> + </div> ); }; } diff --git a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationAdvantages.tsx b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationAdvantages.tsx new file mode 100644 index 00000000000..7b4935b8107 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationAdvantages.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 CheckIcon from '../../../components/icons-components/CheckIcon'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import * as theme from '../../../app/theme'; + +const TRIAL_DURATION_DAYS = 14; + +export default function UpgradeOrganizationAdvantages() { + return ( + <ul className="note"> + <Advantage>{translate('billing.upgrade_box.unlimited_private_projects')}</Advantage> + <Advantage>{translate('billing.upgrade_box.strict_control_private_data')}</Advantage> + <Advantage>{translate('billing.upgrade_box.cancel_anytime')}</Advantage> + <Advantage> + <strong> + {translateWithParameters('billing.upgrade_box.free_trial_x', TRIAL_DURATION_DAYS)} + </strong> + </Advantage> + </ul> + ); +} + +export function Advantage({ children }: { children: React.ReactNode }) { + return ( + <li className="display-flex-center little-spacer-bottom"> + <CheckIcon className="spacer-right" fill={theme.lightGreen} /> + {children} + </li> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx new file mode 100644 index 00000000000..aede9e520bd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx @@ -0,0 +1,115 @@ +/* + * 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 { Link } from 'react-router'; +import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; +import UpgradeOrganizationModal from './UpgradeOrganizationModal'; +import CardPlan from './CardPlan'; +import { Button } from '../../../components/ui/buttons'; +import { translate, hasMessage } from '../../../helpers/l10n'; +import { getSubscriptionPlans } from '../../../api/billing'; + +interface Props { + className?: string; + insideModal?: boolean; + onOrganizationUpgrade: () => void; + organization: T.Organization; +} + +interface State { + subscriptionPlans: T.SubscriptionPlan[]; + upgradeOrganizationModal: boolean; +} + +export default class UpgradeOrganizationBox extends React.PureComponent<Props, State> { + mounted = false; + state: State = { subscriptionPlans: [], upgradeOrganizationModal: false }; + + componentDidMount() { + this.mounted = true; + this.fetchSubscriptionPlans(); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchSubscriptionPlans = () => { + return getSubscriptionPlans().then(subscriptionPlans => { + if (this.mounted) { + this.setState({ subscriptionPlans }); + } + }); + }; + + handleUpgradeClick = () => { + this.setState({ upgradeOrganizationModal: true }); + }; + + handleUpgradeOrganizationModalClose = () => { + if (this.mounted) { + this.setState({ upgradeOrganizationModal: false }); + } + }; + + handleOrganizationUpgrade = () => { + this.props.onOrganizationUpgrade(); + this.handleUpgradeOrganizationModalClose(); + }; + + render() { + if (!hasMessage('billing.upgrade_box.header')) { + return null; + } + + const { subscriptionPlans, upgradeOrganizationModal } = this.state; + const startingPrice = subscriptionPlans[0] && subscriptionPlans[0].price; + + return ( + <> + <CardPlan + className={this.props.className} + startingPrice={startingPrice} + title={translate('billing.upgrade_box.header')}> + <> + <UpgradeOrganizationAdvantages /> + <div className="big-spacer-left"> + <Button className="js-upgrade-organization" onClick={this.handleUpgradeClick}> + {translate('billing.paid_plan.upgrade')} + </Button> + <Link className="spacer-left" target="_blank" to="/about/pricing"> + {translate('billing.pricing.learn_more')} + </Link> + </div> + </> + </CardPlan> + {upgradeOrganizationModal && ( + <UpgradeOrganizationModal + insideModal={this.props.insideModal} + onClose={this.handleUpgradeOrganizationModalClose} + onUpgradeDone={this.handleOrganizationUpgrade} + organization={this.props.organization} + subscriptionPlans={subscriptionPlans} + /> + )} + </> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx new file mode 100644 index 00000000000..fbcd02d76fe --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx @@ -0,0 +1,128 @@ +/* + * 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 UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; +import BillingFormShim from './BillingFormShim'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import Modal from '../../../components/controls/Modal'; +import { ResetButtonLink } from '../../../components/ui/buttons'; +import { getExtensionStart } from '../../../app/components/extensions/utils'; +import { translate } from '../../../helpers/l10n'; +import { withCurrentUser } from '../../../components/hoc/withCurrentUser'; + +const BillingForm = withCurrentUser(BillingFormShim); + +interface Props { + insideModal?: boolean; + onUpgradeDone: () => void; + onClose: () => void; + organization: T.Organization; + subscriptionPlans: T.SubscriptionPlan[]; +} + +interface State { + ready: boolean; +} + +export default class UpgradeOrganizationModal extends React.PureComponent<Props, State> { + mounted = false; + state: State = { ready: false }; + + componentDidMount() { + this.mounted = true; + getExtensionStart('billing/billing').then( + () => { + if (this.mounted) { + this.setState({ ready: true }); + } + }, + () => {} + ); + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + const header = translate('billing.upgrade_box.upgrade_to_paid_plan'); + + if (!this.state.ready) { + return null; + } + + return ( + <Modal + contentLabel={header} + medium={true} + noBackdrop={this.props.insideModal} + onRequestClose={this.props.onClose} + shouldCloseOnOverlayClick={false} + simple={true}> + <div className="modal-simple-head"> + <h2>{header}</h2> + </div> + <BillingForm + onCommit={this.props.onUpgradeDone} + organizationKey={this.props.organization.key} + subscriptionPlans={this.props.subscriptionPlans}> + {({ + onSubmit, + processingUpgrade, + renderFormFields, + renderNextCharge, + renderRecap, + renderSubmitButton + }) => ( + <form id="organization-paid-plan-form" onSubmit={onSubmit}> + <div className="modal-simple-body modal-container"> + <div className="huge-spacer-bottom"> + <p className="spacer-bottom"> + <FormattedMessage + defaultMessage={translate('billing.upgrade.org_x_advantages')} + id="billing.coupon.description" + values={{ + org: <strong>{this.props.organization.name}</strong> + }} + /> + </p> + <UpgradeOrganizationAdvantages /> + </div> + {renderFormFields()} + <div className="big-spacer-top">{renderRecap()}</div> + </div> + <footer className="modal-simple-foot-action"> + <span className="note">{renderNextCharge()}</span> + <div> + <DeferredSpinner className="spacer-right" loading={processingUpgrade} /> + {renderSubmitButton()} + <ResetButtonLink onClick={this.props.onClose}> + {translate('cancel')} + </ResetButtonLink> + </div> + </footer> + </form> + )} + </BillingForm> + </Modal> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__mocks__/BillingFormShim.tsx b/server/sonar-web/src/main/js/apps/create/components/__mocks__/BillingFormShim.tsx index 48f5e0dc984..a5a43958cca 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__mocks__/BillingFormShim.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__mocks__/BillingFormShim.tsx @@ -25,7 +25,11 @@ export default class BillingFormShim extends React.Component<{ children: any }> <div id="BillingFormShim"> {this.props.children({ onSubmit: jest.fn(), + processingUpgrade: true, renderFormFields: () => <div id="form-fields" />, + renderNextCharge: () => <div id="form-next-charge" />, + renderRecap: () => <div id="form-recap" />, + renderSubmitButton: () => <div id="form-submit" />, renderSubmitGroup: () => <div id="submit-group" /> })} </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/components/__tests__/BillingFormShim-test.tsx index 71119febaf6..71119febaf6 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/BillingFormShim-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/BillingFormShim-test.tsx diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx index 2190a5073db..3f848bbc6e4 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx @@ -25,7 +25,7 @@ import { click } from '../../../../helpers/testUtils'; it('should render correctly', () => { expect( shallow( - <CardPlan recommended="Recommended for you" startingPrice="$10" title="Paid Plan"> + <CardPlan recommended="Recommended for you" startingPrice={10} title="Paid Plan"> <div>content</div> </CardPlan> ) @@ -42,12 +42,12 @@ it('should be actionable', () => { expect(wrapper).toMatchSnapshot(); click(wrapper); - wrapper.setProps({ selected: true }); + wrapper.setProps({ selected: true, startingPrice: 0 }); expect(wrapper).toMatchSnapshot(); }); describe('#FreeCardPlan', () => { - it('should render correctly', () => { + it('should render', () => { expect(shallow(<FreeCardPlan hasWarning={false} />)).toMatchSnapshot(); }); @@ -65,7 +65,7 @@ describe('#FreeCardPlan', () => { }); describe('#PaidCardPlan', () => { - it('should render correctly', () => { - expect(shallow(<PaidCardPlan isRecommended={true} startingPrice="$10" />)).toMatchSnapshot(); + it('should render', () => { + expect(shallow(<PaidCardPlan isRecommended={true} startingPrice={10} />)).toMatchSnapshot(); }); }); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationAdvantages-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationAdvantages-test.tsx new file mode 100644 index 00000000000..e922c42cd24 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationAdvantages-test.tsx @@ -0,0 +1,26 @@ +/* + * 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 UpgradeOrganizationAdvantages from '../UpgradeOrganizationAdvantages'; + +it('should render correctly ', () => { + expect(shallow(<UpgradeOrganizationAdvantages />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationBox-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationBox-test.tsx new file mode 100644 index 00000000000..65823c7454a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationBox-test.tsx @@ -0,0 +1,62 @@ +/* + * 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 UpgradeOrganizationBox from '../UpgradeOrganizationBox'; +import { hasMessage } from '../../../../helpers/l10n'; +import { getSubscriptionPlans } from '../../../../api/billing'; +import { waitAndUpdate, click } from '../../../../helpers/testUtils'; + +jest.mock('../../../../helpers/l10n', () => { + const l10n = require.requireActual('../../../../helpers/l10n'); + l10n.hasMessage = jest.fn().mockReturnValue(true); + return l10n; +}); + +jest.mock('../../../../api/billing', () => ({ + getSubscriptionPlans: jest.fn().mockResolvedValue([{ maxNcloc: 100000, price: 10 }]) +})); + +const organization = { key: 'foo', name: 'Foo' }; + +beforeEach(() => { + (hasMessage as jest.Mock<any>).mockClear(); + (getSubscriptionPlans as jest.Mock<any>).mockClear(); +}); + +it('should not render', () => { + (hasMessage as jest.Mock<any>).mockReturnValueOnce(false); + expect( + shallow( + <UpgradeOrganizationBox onOrganizationUpgrade={jest.fn()} organization={organization} /> + ).type() + ).toBeNull(); +}); + +it('should render correctly', async () => { + const wrapper = shallow( + <UpgradeOrganizationBox onOrganizationUpgrade={jest.fn()} organization={organization} /> + ); + await waitAndUpdate(wrapper); + expect(getSubscriptionPlans).toHaveBeenCalled(); + expect(wrapper).toMatchSnapshot(); + click(wrapper.find('Button')); + expect(wrapper.find('UpgradeOrganizationModal').exists()).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/components/common/UpgradeOrganizationBox.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx index 1d6254b8a37..b15d78bb077 100644 --- a/server/sonar-web/src/main/js/components/common/UpgradeOrganizationBox.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx @@ -18,32 +18,27 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { Link } from 'react-router'; -import { translate, hasMessage } from '../../helpers/l10n'; -import './UpgradeOrganizationBox.css'; +import { shallow } from 'enzyme'; +import UpgradeOrganizationModal from '../UpgradeOrganizationModal'; +import { getExtensionStart } from '../../../../app/components/extensions/utils'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; -interface Props { - organization: string; -} +jest.mock('../../../../app/components/extensions/utils', () => ({ + getExtensionStart: jest.fn().mockResolvedValue(undefined) +})); -export default function UpgradeOrganizationBox({ organization }: Props) { - if (!hasMessage('billing.upgrade_box.button')) { - return null; - } - return ( - <div className="boxed-group boxed-group-inner upgrade-organization-box"> - <h3 className="spacer-bottom">{translate('billing.upgrade_box.header')}</h3> - <p>{translate('billing.upgrade_box.text')}</p> - <div className="big-spacer-top"> - <Link - className="button" - to={{ - pathname: `organizations/${organization}/extension/billing/billing`, - query: { page: 'upgrade' } - }}> - {translate('billing.upgrade_box.button')} - </Link> - </div> - </div> +const organization = { key: 'foo', name: 'Foo' }; + +it('should render correctly', async () => { + const wrapper = shallow( + <UpgradeOrganizationModal + onClose={jest.fn()} + onUpgradeDone={jest.fn()} + organization={organization} + subscriptionPlans={[]} + /> ); -} + await waitAndUpdate(wrapper); + expect(getExtensionStart).toHaveBeenCalled(); + expect(wrapper).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/components/__tests__/__snapshots__/BillingFormShim-test.tsx.snap index fc6fb48982e..fc6fb48982e 100644 --- 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/components/__tests__/__snapshots__/BillingFormShim-test.tsx.snap diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap index 028ea133bbe..f98ce7f4c44 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap @@ -1,37 +1,51 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#FreeCardPlan should render correctly 1`] = ` +exports[`#FreeCardPlan should render 1`] = ` <CardPlan + startingPrice={0} title="billing.free_plan.title" > - <ul - className="note" + <div + className="spacer-left" > - <li> - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> </CardPlan> `; exports[`#FreeCardPlan should render disabled with info 1`] = ` <CardPlan disabled={true} + startingPrice={0} title="billing.free_plan.title" > - <ul - className="note" + <div + className="spacer-left" > - <li> - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> <Alert variant="info" > @@ -51,18 +65,25 @@ exports[`#FreeCardPlan should render disabled with info 1`] = ` exports[`#FreeCardPlan should render with warning 1`] = ` <CardPlan selected={true} + startingPrice={0} title="billing.free_plan.title" > - <ul - className="note" + <div + className="spacer-left" > - <li> - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> <Alert variant="warning" > @@ -79,60 +100,13 @@ exports[`#FreeCardPlan should render with warning 1`] = ` </CardPlan> `; -exports[`#PaidCardPlan should render correctly 1`] = ` +exports[`#PaidCardPlan should render 1`] = ` <CardPlan recommended="billing.paid_plan.recommended" - startingPrice="$10" + startingPrice={10} title="billing.paid_plan.title" > - <ul - className="note" - > - <li - className="display-flex-center" - key="0" - > - <CheckIcon - className="spacer-right" - fill="#b0d513" - /> - billing.upgrade_box.unlimited_private_projects - </li> - <li - className="display-flex-center" - key="1" - > - <CheckIcon - className="spacer-right" - fill="#b0d513" - /> - billing.upgrade_box.strict_control_private_data - </li> - <li - className="display-flex-center" - key="2" - > - <CheckIcon - className="spacer-right" - fill="#b0d513" - /> - billing.upgrade_box.cancel_anytime - </li> - <li - className="display-flex-center" - key="3" - > - <CheckIcon - className="spacer-right" - fill="#b0d513" - /> - <strong - key="trial" - > - billing.upgrade_box.free_trial_x.14 - </strong> - </li> - </ul> + <UpgradeOrganizationAdvantages /> <div className="big-spacer-left" > @@ -141,7 +115,7 @@ exports[`#PaidCardPlan should render correctly 1`] = ` onlyActiveOnIndex={false} style={Object {}} target="_blank" - to="/documentation/sonarcloud-pricing/" + to="/about/pricing" > billing.pricing.learn_more </Link> @@ -167,11 +141,6 @@ exports[`should be actionable 1`] = ` /> Free Plan </span> - <span - className="card-plan-price" - > - billing.price_format.0 - </span> </h2> <div className="card-plan-body" @@ -263,7 +232,7 @@ exports[`should render correctly 1`] = ` "price": <span className="card-plan-price" > - $10 + billing.price_format.10 </span>, } } diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap index 4b5035cd307..1003db90587 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap @@ -34,46 +34,64 @@ exports[`should render correctly 1`] = ` `; exports[`should render options correctly 1`] = ` -<span> - <img - alt="SonarCloud" - className="spacer-right" - height={14} - src="/images/sonarcloud-square-logo.svg" - /> - Foo +<div + className="display-flex-space-between" +> <span - className="note little-spacer-left" + className="text-ellipsis flex-1" > - foo + <img + alt="SonarCloud" + className="little-spacer-right" + height={14} + src="/images/sonarcloud-square-logo.svg" + /> + Foo + <span + className="note little-spacer-left" + > + foo + </span> </span> -</span> +</div> `; exports[`should render options correctly 2`] = ` -<span> - <img - alt="github" - className="spacer-right" - height={14} - src="/images/sonarcloud/github.svg" - /> - Bar +<div + className="display-flex-space-between" +> <span - className="note little-spacer-left" + className="text-ellipsis flex-1" > - bar + <img + alt="github" + className="little-spacer-right" + height={14} + src="/images/sonarcloud/github.svg" + /> + Bar + <span + className="note little-spacer-left" + > + bar + </span> </span> -</span> +</div> `; exports[`should render options correctly 3`] = ` -<span> - Foo +<div + className="display-flex-space-between" +> <span - className="note little-spacer-left" + className="text-ellipsis flex-1" > - foo + Foo + <span + className="note little-spacer-left" + > + foo + </span> </span> -</span> +</div> `; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationAdvantages-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationAdvantages-test.tsx.snap new file mode 100644 index 00000000000..9a93403230e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationAdvantages-test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<ul + className="note" +> + <Advantage> + billing.upgrade_box.unlimited_private_projects + </Advantage> + <Advantage> + billing.upgrade_box.strict_control_private_data + </Advantage> + <Advantage> + billing.upgrade_box.cancel_anytime + </Advantage> + <Advantage> + <strong> + billing.upgrade_box.free_trial_x.14 + </strong> + </Advantage> +</ul> +`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap new file mode 100644 index 00000000000..249f04ebdce --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Fragment> + <CardPlan + startingPrice={10} + title="billing.upgrade_box.header" + > + <UpgradeOrganizationAdvantages /> + <div + className="big-spacer-left" + > + <Button + className="js-upgrade-organization" + onClick={[Function]} + > + billing.paid_plan.upgrade + </Button> + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to="/about/pricing" + > + billing.pricing.learn_more + </Link> + </div> + </CardPlan> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationModal-test.tsx.snap new file mode 100644 index 00000000000..f826be7df3e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationModal-test.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Modal + contentLabel="billing.upgrade_box.upgrade_to_paid_plan" + medium={true} + onRequestClose={[MockFunction]} + shouldCloseOnOverlayClick={false} + simple={true} +> + <div + className="modal-simple-head" + > + <h2> + billing.upgrade_box.upgrade_to_paid_plan + </h2> + </div> + <Connect(withCurrentUser(BillingFormShim)) + onCommit={[MockFunction]} + organizationKey="foo" + subscriptionPlans={Array []} + > + <Component /> + </Connect(withCurrentUser(BillingFormShim))> +</Modal> +`; 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 fecfbdb42b2..9977f8a739b 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 @@ -24,10 +24,8 @@ import { times } from 'lodash'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { Helmet } from 'react-helmet'; -import { FormattedMessage } from 'react-intl'; -import { Link, withRouter, WithRouterProps } from 'react-router'; +import { withRouter, WithRouterProps } from 'react-router'; import { - formatPrice, ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP, parseQuery, serializeQuery, @@ -410,7 +408,6 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr ? translate('onboarding.import_organization.personal.page.header') : translate('onboarding.create_organization.page.header'); const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; - const formattedPrice = formatPrice(startedPrice); return ( <> @@ -421,19 +418,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr {!importPersonalOrg && 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> - ) - }} - /> + {translate('onboarding.create_organization.page.description')} </p> )} </header> 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 index 25a9964d360..c9ba5a9cc0d 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx @@ -20,7 +20,6 @@ import * as React from 'react'; import { FreeCardPlan, PaidCardPlan } from '../components/CardPlan'; import { translate } from '../../../helpers/l10n'; -import { AlmOrganization, AlmApplication } from '../../../app/types'; export enum Plan { Free = 'free', @@ -28,11 +27,11 @@ export enum Plan { } interface Props { - almApplication?: AlmApplication; - almOrganization?: AlmOrganization; + almApplication?: T.AlmApplication; + almOrganization?: T.AlmOrganization; onChange: (plan: Plan) => void; plan: Plan; - startingPrice: string; + startingPrice: number; } export default class PlanSelect extends React.PureComponent<Props> { diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx index adf99b376d2..7d3cfbfe798 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx @@ -18,9 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import BillingFormShim from './BillingFormShim'; import PlanSelect, { Plan } from './PlanSelect'; -import { formatPrice } from './utils'; +import BillingFormShim from '../components/BillingFormShim'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import Step from '../../tutorials/components/Step'; import { SubmitButton } from '../../../components/ui/buttons'; @@ -96,7 +95,7 @@ export default class PlanStep extends React.PureComponent<Props, State> { renderForm = () => { const { submitting } = this.state; const { subscriptionPlans } = this.props; - const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; + const startingPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; return ( <div className="boxed-group-inner"> {this.state.ready && ( @@ -106,7 +105,7 @@ export default class PlanStep extends React.PureComponent<Props, State> { almOrganization={this.props.almOrganization} onChange={this.handlePlanChange} plan={this.state.plan} - startingPrice={formatPrice(startedPrice)} + startingPrice={startingPrice} /> {this.state.plan === Plan.Paid ? ( 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 index 8851e7d869a..fcc2a755f11 100644 --- 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 @@ -56,6 +56,6 @@ it('should recommend paid plan and disable free plan', () => { function shallowRender(props: Partial<PlanSelect['props']> = {}) { return shallow( - <PlanSelect onChange={jest.fn()} plan={Plan.Free} startingPrice="10" {...props} /> + <PlanSelect onChange={jest.fn()} plan={Plan.Free} startingPrice={10} {...props} /> ); } 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 f2bb69deb5d..5b7b823d209 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 @@ -101,24 +101,7 @@ exports[`should render with auto tab displayed 1`] = ` <p className="page-description" > - <FormattedMessage - defaultMessage="onboarding.create_organization.page.description" - id="onboarding.create_organization.page.description" - values={ - Object { - "break": <br />, - "more": <Link - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/documentation/sonarcloud-pricing/" - > - learn_more - </Link>, - "price": "billing.price_format.10", - } - } - /> + onboarding.create_organization.page.description </p> </header> <Tabs @@ -203,24 +186,7 @@ exports[`should render with auto tab selected and manual disabled 2`] = ` <p className="page-description" > - <FormattedMessage - defaultMessage="onboarding.create_organization.page.description" - id="onboarding.create_organization.page.description" - values={ - Object { - "break": <br />, - "more": <Link - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/documentation/sonarcloud-pricing/" - > - learn_more - </Link>, - "price": "billing.price_format.10", - } - } - /> + onboarding.create_organization.page.description </p> </header> <Tabs @@ -342,24 +308,7 @@ exports[`should render with manual tab displayed 1`] = ` <p className="page-description" > - <FormattedMessage - defaultMessage="onboarding.create_organization.page.description" - id="onboarding.create_organization.page.description" - values={ - Object { - "break": <br />, - "more": <Link - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/documentation/sonarcloud-pricing/" - > - learn_more - </Link>, - "price": "billing.price_format.10", - } - } - /> + onboarding.create_organization.page.description </p> </header> <ManualOrganizationCreate @@ -414,24 +363,7 @@ exports[`should render with organization bind page 2`] = ` <p className="page-description" > - <FormattedMessage - defaultMessage="onboarding.create_organization.page.description" - id="onboarding.create_organization.page.description" - values={ - Object { - "break": <br />, - "more": <Link - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/documentation/sonarcloud-pricing/" - > - learn_more - </Link>, - "price": "billing.price_format.10", - } - } - /> + onboarding.create_organization.page.description </p> </header> <Tabs @@ -551,24 +483,7 @@ exports[`should switch tabs 1`] = ` <p className="page-description" > - <FormattedMessage - defaultMessage="onboarding.create_organization.page.description" - id="onboarding.create_organization.page.description" - values={ - Object { - "break": <br />, - "more": <Link - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/documentation/sonarcloud-pricing/" - > - learn_more - </Link>, - "price": "billing.price_format.10", - } - } - /> + onboarding.create_organization.page.description </p> </header> <Tabs 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 index 25680a523c4..cb99c558c4a 100644 --- 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 @@ -18,7 +18,7 @@ exports[`should render and select 1`] = ` key="paid" onClick={[Function]} selected={false} - startingPrice="10" + startingPrice={10} /> </div> `; @@ -41,7 +41,7 @@ exports[`should render and select 2`] = ` key="paid" onClick={[Function]} selected={true} - startingPrice="10" + startingPrice={10} /> </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 index a979c50685f..451e76df78d 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap @@ -35,7 +35,7 @@ exports[`should preselect paid plan 1`] = ` } onChange={[Function]} plan="paid" - startingPrice="billing.price_format.100" + startingPrice={100} /> <Connect(withCurrentUser(BillingFormShim)) onCommit={[MockFunction]} @@ -94,7 +94,7 @@ exports[`should render and use free plan 2`] = ` <PlanSelect onChange={[Function]} plan="free" - startingPrice="billing.price_format.100" + startingPrice={100} /> <form className="display-flex-center big-spacer-top" @@ -137,7 +137,7 @@ exports[`should upgrade 1`] = ` <PlanSelect onChange={[Function]} plan="paid" - startingPrice="billing.price_format.100" + startingPrice={100} /> <Connect(withCurrentUser(BillingFormShim)) onCommit={[MockFunction]} diff --git a/server/sonar-web/src/main/js/apps/create/organization/utils.ts b/server/sonar-web/src/main/js/apps/create/organization/utils.ts index 4d5d72200f2..bc2688be093 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/utils.ts +++ b/server/sonar-web/src/main/js/apps/create/organization/utils.ts @@ -35,8 +35,6 @@ export const ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP = export const ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP = 'sonarcloud.import_org.redirect_to_projects'; -export const TRIAL_DURATION_DAYS = 14; - export enum Step { OrganizationDetails, Plan diff --git a/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx b/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx index aa700e4ce57..fc25d51b8ec 100644 --- a/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx @@ -18,6 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as classNames from 'classnames'; +import { identity } from 'lodash'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router'; import * as theme from '../../../app/theme'; @@ -26,8 +28,11 @@ import CheckIcon from '../../../components/icons-components/CheckIcon'; import Tooltip from '../../../components/controls/Tooltip'; import { getBaseUrl, getProjectUrl } from '../../../helpers/urls'; import { translate } from '../../../helpers/l10n'; +import LockIcon from '../../../components/icons-components/LockIcon'; interface Props { + disabled: boolean; + highlightUpgradeBox: (highlight: boolean) => void; identityProvider: T.IdentityProvider; repository: T.AlmRepository; selected: boolean; @@ -35,51 +40,83 @@ interface Props { } export default class AlmRepositoryItem extends React.PureComponent<Props> { - handleChange = () => { - this.props.toggleRepository(this.props.repository); + handleMouseEnter = () => { + this.props.highlightUpgradeBox(true); + }; + + handleMouseLeave = () => { + this.props.highlightUpgradeBox(false); + }; + + handleToggle = () => { + if (!this.props.disabled && !this.props.repository.linkedProjectKey) { + this.props.toggleRepository(this.props.repository); + } }; render() { - const { identityProvider, repository, selected } = this.props; + const { disabled, identityProvider, repository, selected } = this.props; const alreadyImported = Boolean(repository.linkedProjectKey); return ( - <> - <Checkbox - checked={selected || alreadyImported} - disabled={alreadyImported || repository.private} - onCheck={this.handleChange}> - <img - alt={identityProvider.name} - className="spacer-left" - height={14} - src={`${getBaseUrl()}/images/sonarcloud/${identityProvider.key}.svg`} - style={{ opacity: alreadyImported || repository.private ? 0.5 : 1 }} - width={14} - /> - <span className="spacer-left">{this.props.repository.label}</span> - </Checkbox> - {repository.linkedProjectKey && ( - <span className="big-spacer-left"> - <CheckIcon className="little-spacer-right" fill={theme.green} /> - <FormattedMessage - defaultMessage={translate('onboarding.create_project.repository_imported')} - id="onboarding.create_project.repository_imported" - values={{ - link: ( - <Link to={getProjectUrl(repository.linkedProjectKey)}> - {translate('onboarding.create_project.see_project')} - </Link> - ) - }} - /> - </span> - )} - {repository.private && ( - <Tooltip overlay={translate('onboarding.import_organization.private.disabled')}> - <div className="outline-badge spacer-left">{translate('visibility.private')}</div> - </Tooltip> - )} - </> + <Tooltip + overlay={ + disabled + ? translate('onboarding.create_project.subscribe_to_import_private_repositories') + : undefined + }> + <li> + <div + className={classNames('create-project-repository', { + disabled, + imported: alreadyImported, + selected + })} + onClick={this.handleToggle} + onMouseEnter={disabled ? this.handleMouseEnter : undefined} + onMouseLeave={disabled ? this.handleMouseLeave : undefined} + role="listitem"> + <div className="flex-1 display-flex-center"> + {disabled ? ( + <LockIcon fill={theme.disableGrayText} /> + ) : ( + <Checkbox + checked={selected || alreadyImported} + disabled={disabled || alreadyImported} + onCheck={identity} + /> + )} + <img + alt={identityProvider.name} + className={classNames('spacer-left', { 'icon-half-transparent': disabled })} + height={14} + src={`${getBaseUrl()}/images/sonarcloud/${identityProvider.key}.svg`} + width={14} + /> + <span className="spacer-left">{this.props.repository.label}</span> + {repository.private && ( + <div className="outline-badge spacer-left">{translate('visibility.private')}</div> + )} + </div> + + {repository.linkedProjectKey && ( + <span> + <CheckIcon className="little-spacer-right" fill={theme.green} /> + <FormattedMessage + defaultMessage={translate('onboarding.create_project.repository_imported')} + id="onboarding.create_project.repository_imported" + values={{ + link: ( + <Link to={getProjectUrl(repository.linkedProjectKey)}> + {translate('onboarding.create_project.see_project')} + </Link> + ) + }} + /> + </span> + )} + </div> + </li> + </Tooltip> ); } } diff --git a/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx index 70f4f76cacd..f7e3bf6fb91 100644 --- a/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx @@ -31,6 +31,7 @@ import { save } from '../../../helpers/storage'; interface Props { almApplication: T.AlmApplication; boundOrganizations: T.Organization[]; + onOrganizationUpgrade: () => void; onProjectCreate: (projectKeys: string[], organization: string) => void; organization?: string; } @@ -40,11 +41,21 @@ interface State { } export default class AutoProjectCreate extends React.PureComponent<Props, State> { + mounted = false; + constructor(props: Props) { super(props); this.state = { selectedOrganization: this.getInitialSelectedOrganization(props) }; } + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + getInitialSelectedOrganization(props: Props) { if (props.organization) { return props.organization; @@ -89,6 +100,8 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State> } const { selectedOrganization } = this.state; + const organization = boundOrganizations.find(o => o.key === selectedOrganization); + return ( <> <OrganizationInput @@ -97,11 +110,12 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State> organization={selectedOrganization} organizations={this.props.boundOrganizations} /> - {selectedOrganization && ( + {organization && ( <RemoteRepositories almApplication={almApplication} + onOrganizationUpgrade={this.props.onOrganizationUpgrade} onProjectCreate={onProjectCreate} - organization={selectedOrganization} + organization={organization} /> )} </> diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index 882bf5b36a9..27e03f15e47 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -33,9 +33,11 @@ import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations'; import { translate } from '../../../helpers/l10n'; import { getProjectUrl, getOrganizationUrl } from '../../../helpers/urls'; import '../../../app/styles/sonarcloud.css'; +import './style.css'; interface Props { currentUser: T.LoggedInUser; + fetchMyOrganizations: () => Promise<void>; skipOnboarding: () => void; userOrganizations: T.Organization[]; } @@ -162,6 +164,7 @@ export class CreateProjectPage extends React.PureComponent<Props & WithRouterPro boundOrganizations={userOrganizations.filter( ({ alm, actions = {} }) => alm && actions.provision )} + onOrganizationUpgrade={this.props.fetchMyOrganizations} onProjectCreate={this.handleProjectCreate} organization={state.organization} /> diff --git a/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx b/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx index c2a895ecc60..60f2e637ff0 100644 --- a/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx @@ -18,30 +18,42 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as classNames from 'classnames'; import AlmRepositoryItem from './AlmRepositoryItem'; +import SetupProjectBox from './SetupProjectBox'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; -import { getRepositories, provisionProject } from '../../../api/alm-integration'; -import { SubmitButton } from '../../../components/ui/buttons'; -import { translate } from '../../../helpers/l10n'; +import UpgradeOrganizationBox from '../components/UpgradeOrganizationBox'; +import { getRepositories } from '../../../api/alm-integration'; +import { isDefined } from '../../../helpers/types'; +import { translateWithParameters } from '../../../helpers/l10n'; +import { Alert } from '../../../components/ui/Alert'; interface Props { almApplication: T.AlmApplication; + onOrganizationUpgrade: () => void; onProjectCreate: (projectKeys: string[], organization: string) => void; - organization: string; + organization: T.Organization; } type SelectedRepositories = { [key: string]: T.AlmRepository | undefined }; interface State { + highlight: boolean; loading: boolean; repositories: T.AlmRepository[]; selectedRepositories: SelectedRepositories; - submitting: boolean; + successfullyUpgraded: boolean; } export default class RemoteRepositories extends React.PureComponent<Props, State> { mounted = false; - state: State = { loading: true, repositories: [], selectedRepositories: {}, submitting: false }; + state: State = { + highlight: false, + loading: true, + repositories: [], + selectedRepositories: {}, + successfullyUpgraded: false + }; componentDidMount() { this.mounted = true; @@ -51,7 +63,7 @@ export default class RemoteRepositories extends React.PureComponent<Props, State componentDidUpdate(prevProps: Props) { const { organization } = this.props; if (prevProps.organization !== organization) { - this.setState({ loading: true }); + this.setState({ loading: true, selectedRepositories: {} }); this.fetchRepositories(); } } @@ -62,7 +74,7 @@ export default class RemoteRepositories extends React.PureComponent<Props, State fetchRepositories = () => { const { organization } = this.props; - return getRepositories({ organization }).then( + return getRepositories({ organization: organization.key }).then( ({ repositories }) => { if (this.mounted) { this.setState({ loading: false, repositories }); @@ -76,26 +88,14 @@ export default class RemoteRepositories extends React.PureComponent<Props, State ); }; - handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { - event.preventDefault(); + handleHighlightUpgradeBox = (highlight: boolean) => { + this.setState({ highlight }); + }; - if (this.isValid()) { - const { selectedRepositories } = this.state; - this.setState({ submitting: true }); - provisionProject({ - installationKeys: Object.keys(selectedRepositories).filter(key => { - const repositories = selectedRepositories[key]; - return repositories && !repositories.private; - }), - organization: this.props.organization - }).then( - ({ projects }) => - this.props.onProjectCreate( - projects.map(project => project.projectKey), - this.props.organization - ), - this.handleProvisionFail - ); + handleOrganizationUpgrade = () => { + this.props.onOrganizationUpgrade(); + if (this.mounted) { + this.setState({ successfullyUpgraded: true }); } }; @@ -110,18 +110,12 @@ export default class RemoteRepositories extends React.PureComponent<Props, State updateSelectedRepositories[newRepository.installationKey] = newRepository; } }); - return { selectedRepositories: updateSelectedRepositories, submitting: false }; + return { selectedRepositories: updateSelectedRepositories }; }); } }); }; - isValid = () => { - return this.state.repositories.some(repo => - Boolean(this.state.selectedRepositories[repo.installationKey]) - ); - }; - toggleRepository = (repository: T.AlmRepository) => { this.setState(({ selectedRepositories }) => ({ selectedRepositories: { @@ -134,29 +128,60 @@ export default class RemoteRepositories extends React.PureComponent<Props, State }; render() { - const { loading, selectedRepositories, submitting } = this.state; - const { almApplication } = this.props; + const { highlight, loading, repositories, selectedRepositories } = this.state; + const { almApplication, organization } = this.props; + const isPaidOrg = organization.subscription === 'PAID'; + const hasPrivateRepositories = repositories.some(repository => Boolean(repository.private)); + const showUpgradebox = + !isPaidOrg && hasPrivateRepositories && organization.actions && organization.actions.admin; + return ( - <DeferredSpinner loading={loading}> - <form onSubmit={this.handleFormSubmit}> - <div className="form-field"> + <div className="create-project"> + <div className="flex-1 huge-spacer-right"> + {this.state.successfullyUpgraded && ( + <Alert variant="success"> + {translateWithParameters( + 'onboarding.create_project.subscribtion_success_x', + organization.name + )} + </Alert> + )} + <DeferredSpinner loading={loading}> <ul> - {this.state.repositories.map(repo => ( - <li className="big-spacer-bottom" key={repo.installationKey}> - <AlmRepositoryItem - identityProvider={almApplication} - repository={repo} - selected={Boolean(selectedRepositories[repo.installationKey])} - toggleRepository={this.toggleRepository} - /> - </li> + {repositories.map(repo => ( + <AlmRepositoryItem + disabled={Boolean(repo.private && !isPaidOrg)} + highlightUpgradeBox={this.handleHighlightUpgradeBox} + identityProvider={almApplication} + key={repo.installationKey} + repository={repo} + selected={Boolean(selectedRepositories[repo.installationKey])} + toggleRepository={this.toggleRepository} + /> ))} </ul> + </DeferredSpinner> + </div> + {organization && ( + <div className="huge-spacer-left"> + <SetupProjectBox + onProjectCreate={this.props.onProjectCreate} + onProvisionFail={this.handleProvisionFail} + organization={organization} + selectedRepositories={Object.keys(selectedRepositories) + .map(r => selectedRepositories[r]) + .filter(isDefined)} + /> + {showUpgradebox && ( + <UpgradeOrganizationBox + className={classNames({ highlight })} + onOrganizationUpgrade={this.handleOrganizationUpgrade} + organization={organization} + /> + )} </div> - <SubmitButton disabled={!this.isValid() || submitting}>{translate('setup')}</SubmitButton> - <DeferredSpinner className="spacer-left" loading={submitting} /> - </form> - </DeferredSpinner> + )} + </div> ); } } diff --git a/server/sonar-web/src/main/js/apps/create/project/SetupProjectBox.tsx b/server/sonar-web/src/main/js/apps/create/project/SetupProjectBox.tsx new file mode 100644 index 00000000000..2a086905dda --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/SetupProjectBox.tsx @@ -0,0 +1,141 @@ +/* + * 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 { partition } from 'lodash'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import { SubmitButton } from '../../../components/ui/buttons'; +import { provisionProject } from '../../../api/alm-integration'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; + +interface Props { + onProjectCreate: (projectKeys: string[], organization: string) => void; + onProvisionFail: () => Promise<void>; + organization: T.Organization; + selectedRepositories: T.AlmRepository[]; +} + +interface State { + submitting: boolean; +} + +export default class SetupProjectBox extends React.PureComponent<Props, State> { + mounted = false; + state: State = { submitting: false }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + canSubmit = () => { + return !this.state.submitting && this.props.selectedRepositories.length > 0; + }; + + handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (this.canSubmit()) { + const { selectedRepositories } = this.props; + this.setState({ submitting: true }); + provisionProject({ + installationKeys: selectedRepositories.map(repo => repo.installationKey), + organization: this.props.organization.key + }).then( + ({ projects }) => + this.props.onProjectCreate( + projects.map(project => project.projectKey), + this.props.organization.key + ), + this.handleProvisionFail + ); + } + }; + + handleProvisionFail = () => { + return this.props.onProvisionFail().then(() => { + if (this.mounted) { + this.setState({ submitting: false }); + } + }); + }; + + render() { + const { selectedRepositories } = this.props; + const hasSelectedRepositories = selectedRepositories.length > 0; + const [privateRepos = [], publicRepos = []] = partition( + selectedRepositories, + repo => repo.private + ); + return ( + <form + className={classNames('create-project-setup boxed-group', { + open: hasSelectedRepositories + })} + onSubmit={this.handleFormSubmit}> + <div className="boxed-group-header"> + <h2 className="spacer-top"> + {selectedRepositories.length > 1 + ? translateWithParameters( + 'onboarding.create_project.x_repositories_selected', + selectedRepositories.length + ) + : translate('onboarding.create_project.1_repository_selected')} + </h2> + </div> + <div className="boxed-group-inner"> + <div className="flex-1"> + {publicRepos.length === 1 && ( + <p>{translate('onboarding.create_project.1_repository_created_as_public')}</p> + )} + {publicRepos.length > 1 && ( + <p> + {translateWithParameters( + 'onboarding.create_project.x_repository_created_as_public', + publicRepos.length + )} + </p> + )} + {privateRepos.length === 1 && ( + <p>{translate('onboarding.create_project.1_repository_created_as_private')}</p> + )} + {privateRepos.length > 1 && ( + <p> + {translateWithParameters( + 'onboarding.create_project.x_repository_created_as_private', + privateRepos.length + )} + </p> + )} + </div> + <div> + <SubmitButton className="button-large" disabled={this.state.submitting}> + {translate('setup')} + </SubmitButton> + <DeferredSpinner className="spacer-left" loading={this.state.submitting} /> + </div> + </div> + </form> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx index 79edff1ed9a..94a6c184f1e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx @@ -49,10 +49,14 @@ it('should render selected', () => { expect(getWrapper({ selected: true })).toMatchSnapshot(); }); -it('should render disabled', () => { +it('should render imported', () => { expect(getWrapper({ repository: repositories[0] })).toMatchSnapshot(); }); +it('should render disabed', () => { + expect(getWrapper({ disabled: true, repository: repositories[1] })).toMatchSnapshot(); +}); + it('should render private repositories', () => { expect(getWrapper({ repository: { ...repositories[1], private: true } })).toMatchSnapshot(); }); @@ -60,6 +64,8 @@ it('should render private repositories', () => { function getWrapper(props = {}) { return shallow( <AlmRepositoryItem + disabled={false} + highlightUpgradeBox={jest.fn()} identityProvider={identityProviders} repository={repositories[1]} selected={false} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx index a7664c362be..4bdfc5a10fe 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx @@ -29,11 +29,27 @@ const almApplication = { name: 'GitHub' }; +const boundOrganizations: T.Organization[] = [ + { + actions: { admin: true }, + alm: { key: 'github', url: '' }, + key: 'foo', + name: 'Foo', + subscription: 'FREE' + }, + { + alm: { key: 'github', url: '' }, + key: 'bar', + name: 'Bar', + subscription: 'FREE' + } +]; + it('should display the provider app install button', () => { expect(shallowRender({ boundOrganizations: [] })).toMatchSnapshot(); }); -it('should display the bounded organizations dropdown with the list of repositories', () => { +it('should display the bound organizations dropdown with the remote repositories', () => { expect(shallowRender({ organization: 'foo' })).toMatchSnapshot(); }); @@ -41,10 +57,8 @@ function shallowRender(props: Partial<AutoProjectCreate['props']> = {}) { return shallow( <AutoProjectCreate almApplication={almApplication} - boundOrganizations={[ - { alm: { key: 'github', url: '' }, key: 'foo', name: 'Foo' }, - { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' } - ]} + boundOrganizations={boundOrganizations} + onOrganizationUpgrade={jest.fn()} onProjectCreate={jest.fn()} organization="" {...props} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx index 42529ab5b5b..c37b1d1f4fb 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx @@ -20,8 +20,8 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import RemoteRepositories from '../RemoteRepositories'; -import { getRepositories, provisionProject } from '../../../../api/alm-integration'; -import { waitAndUpdate, submit } from '../../../../helpers/testUtils'; +import { getRepositories } from '../../../../api/alm-integration'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; jest.mock('../../../../api/alm-integration', () => ({ getRepositories: jest.fn().mockResolvedValue({ @@ -32,13 +32,9 @@ jest.mock('../../../../api/alm-integration', () => ({ linkedProjectKey: 'proj_cool', linkedProjectName: 'Proj Cool' }, - { - label: 'Awesome Project', - installationKey: 'github/awesome' - } + { label: 'Awesome Project', installationKey: 'github/awesome' } ] - }), - provisionProject: jest.fn().mockResolvedValue({ projects: [{ projectKey: 'awesome' }] }) + }) })); const almApplication = { @@ -49,45 +45,60 @@ const almApplication = { name: 'GitHub' }; +const organization: T.Organization = { + alm: { key: 'github', url: '' }, + key: 'sonarsource', + name: 'SonarSource', + subscription: 'FREE' +}; + beforeEach(() => { (getRepositories as jest.Mock<any>).mockClear(); - (provisionProject as jest.Mock<any>).mockClear(); }); it('should display the list of repositories', async () => { const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); await waitAndUpdate(wrapper); - expect(getRepositories).toHaveBeenCalledWith({ organization: 'sonarsource' }); expect(wrapper).toMatchSnapshot(); + expect(getRepositories).toHaveBeenCalledWith({ organization: 'sonarsource' }); }); -it('should correctly create a project', async () => { - const onProjectCreate = jest.fn(); - const wrapper = shallowRender({ onProjectCreate }); - (wrapper.instance() as RemoteRepositories).toggleRepository({ - label: 'Awesome Project', - installationKey: 'github/awesome' +it('should display the organization upgrade box', async () => { + (getRepositories as jest.Mock<any>).mockResolvedValueOnce({ + repositories: [{ label: 'Foo Project', installationKey: 'github/foo', private: true }] }); + const wrapper = shallowRender({ organization: { ...organization, actions: { admin: true } } }); await waitAndUpdate(wrapper); + expect(wrapper.find('UpgradeOrganizationBox')).toMatchSnapshot(); + wrapper.find('UpgradeOrganizationBox').prop<Function>('onOrganizationUpgrade')(); + expect(wrapper.find('Alert[variant="success"]').exists()).toBe(true); +}); - expect(wrapper.find('SubmitButton')).toMatchSnapshot(); - submit(wrapper.find('form')); - expect(provisionProject).toBeCalledWith({ - installationKeys: ['github/awesome'], - organization: 'sonarsource' +it('should not display the organization upgrade box', () => { + (getRepositories as jest.Mock<any>).mockResolvedValueOnce({ + repositories: [{ label: 'Bar Project', installationKey: 'github/bar', private: true }] + }); + const wrapper = shallowRender({ + organization: { + actions: { admin: true }, + alm: { key: 'github', url: '' }, + key: 'foobar', + name: 'FooBar', + subscription: 'PAID' + } }); - await waitAndUpdate(wrapper); - expect(onProjectCreate).toBeCalledWith(['awesome'], 'sonarsource'); + expect(wrapper.find('UpgradeOrganizationBox').exists()).toBe(false); }); function shallowRender(props: Partial<RemoteRepositories['props']> = {}) { return shallow( <RemoteRepositories almApplication={almApplication} + onOrganizationUpgrade={jest.fn()} onProjectCreate={jest.fn()} - organization="sonarsource" + organization={organization} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx new file mode 100644 index 00000000000..d2168beaf42 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx @@ -0,0 +1,69 @@ +/* + * 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 SetupProjectBox from '../SetupProjectBox'; +import { waitAndUpdate, submit } from '../../../../helpers/testUtils'; +import { provisionProject } from '../../../../api/alm-integration'; + +jest.mock('../../../../api/alm-integration', () => ({ + provisionProject: jest + .fn() + .mockResolvedValue({ projects: [{ projectKey: 'awesome' }, { projectKey: 'foo' }] }) +})); + +const organization: T.Organization = { + alm: { key: 'github', url: '' }, + key: 'sonarsource', + name: 'SonarSource', + subscription: 'FREE' +}; + +const selectedRepositories = [ + { label: 'Awesome Project', installationKey: 'github/awesome' }, + { label: 'Foo', installationKey: 'github/foo', private: true } +]; + +it('should correctly create projects', async () => { + const onProjectCreate = jest.fn(); + const wrapper = shallowRender({ onProjectCreate }); + + expect(wrapper).toMatchSnapshot(); + submit(wrapper.find('form')); + expect(provisionProject).toBeCalledWith({ + installationKeys: ['github/awesome', 'github/foo'], + organization: 'sonarsource' + }); + + await waitAndUpdate(wrapper); + expect(onProjectCreate).toBeCalledWith(['awesome', 'foo'], 'sonarsource'); +}); + +function shallowRender(props: Partial<SetupProjectBox['props']> = {}) { + return shallow( + <SetupProjectBox + onProjectCreate={jest.fn()} + onProvisionFail={jest.fn()} + organization={organization} + selectedRepositories={selectedRepositories} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap index e2c87e1c800..12bde76d9dc 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap @@ -1,155 +1,211 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`should render correctly 1`] = ` -<Fragment> - <Checkbox - checked={false} - onCheck={[Function]} - thirdState={false} - > - <img - alt="Foo Provider" - className="spacer-left" - height={14} - src="/images/sonarcloud/foo.svg" - style={ - Object { - "opacity": 1, - } - } - width={14} - /> - <span - className="spacer-left" +<Tooltip> + <li> + <div + className="create-project-repository" + onClick={[Function]} + role="listitem" + > + <div + className="flex-1 display-flex-center" + > + <Checkbox + checked={false} + disabled={false} + onCheck={[Function]} + thirdState={false} + /> + <img + alt="Foo Provider" + className="spacer-left" + height={14} + src="/images/sonarcloud/foo.svg" + width={14} + /> + <span + className="spacer-left" + > + Awesome Project + </span> + </div> + </div> + </li> +</Tooltip> +`; + +exports[`should render disabed 1`] = ` +<Tooltip + overlay="onboarding.create_project.subscribe_to_import_private_repositories" +> + <li> + <div + className="create-project-repository disabled" + onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + role="listitem" > - Awesome Project - </span> - </Checkbox> -</Fragment> + <div + className="flex-1 display-flex-center" + > + <LockIcon + fill="#bbb" + /> + <img + alt="Foo Provider" + className="spacer-left icon-half-transparent" + height={14} + src="/images/sonarcloud/foo.svg" + width={14} + /> + <span + className="spacer-left" + > + Awesome Project + </span> + </div> + </div> + </li> +</Tooltip> `; -exports[`should render disabled 1`] = ` -<Fragment> - <Checkbox - checked={true} - disabled={true} - onCheck={[Function]} - thirdState={false} - > - <img - alt="Foo Provider" - className="spacer-left" - height={14} - src="/images/sonarcloud/foo.svg" - style={ - Object { - "opacity": 0.5, - } - } - width={14} - /> - <span - className="spacer-left" +exports[`should render imported 1`] = ` +<Tooltip> + <li> + <div + className="create-project-repository imported" + onClick={[Function]} + role="listitem" > - Cool Project - </span> - </Checkbox> - <span - className="big-spacer-left" - > - <CheckIcon - className="little-spacer-right" - fill="#00aa00" - /> - <FormattedMessage - defaultMessage="onboarding.create_project.repository_imported" - id="onboarding.create_project.repository_imported" - values={ - Object { - "link": <Link - onlyActiveOnIndex={false} - style={Object {}} - to={ - Object { - "pathname": "/dashboard", - "query": Object { - "branch": undefined, - "id": "proj_cool", - }, - } + <div + className="flex-1 display-flex-center" + > + <Checkbox + checked={true} + disabled={true} + onCheck={[Function]} + thirdState={false} + /> + <img + alt="Foo Provider" + className="spacer-left" + height={14} + src="/images/sonarcloud/foo.svg" + width={14} + /> + <span + className="spacer-left" + > + Cool Project + </span> + </div> + <span> + <CheckIcon + className="little-spacer-right" + fill="#00aa00" + /> + <FormattedMessage + defaultMessage="onboarding.create_project.repository_imported" + id="onboarding.create_project.repository_imported" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "branch": undefined, + "id": "proj_cool", + }, + } + } + > + onboarding.create_project.see_project + </Link>, } - > - onboarding.create_project.see_project - </Link>, - } - } - /> - </span> -</Fragment> + } + /> + </span> + </div> + </li> +</Tooltip> `; exports[`should render private repositories 1`] = ` -<Fragment> - <Checkbox - checked={false} - disabled={true} - onCheck={[Function]} - thirdState={false} - > - <img - alt="Foo Provider" - className="spacer-left" - height={14} - src="/images/sonarcloud/foo.svg" - style={ - Object { - "opacity": 0.5, - } - } - width={14} - /> - <span - className="spacer-left" - > - Awesome Project - </span> - </Checkbox> - <Tooltip - overlay="onboarding.import_organization.private.disabled" - > +<Tooltip> + <li> <div - className="outline-badge spacer-left" + className="create-project-repository" + onClick={[Function]} + role="listitem" > - visibility.private + <div + className="flex-1 display-flex-center" + > + <Checkbox + checked={false} + disabled={false} + onCheck={[Function]} + thirdState={false} + /> + <img + alt="Foo Provider" + className="spacer-left" + height={14} + src="/images/sonarcloud/foo.svg" + width={14} + /> + <span + className="spacer-left" + > + Awesome Project + </span> + <div + className="outline-badge spacer-left" + > + visibility.private + </div> + </div> </div> - </Tooltip> -</Fragment> + </li> +</Tooltip> `; exports[`should render selected 1`] = ` -<Fragment> - <Checkbox - checked={true} - onCheck={[Function]} - thirdState={false} - > - <img - alt="Foo Provider" - className="spacer-left" - height={14} - src="/images/sonarcloud/foo.svg" - style={ - Object { - "opacity": 1, - } - } - width={14} - /> - <span - className="spacer-left" +<Tooltip> + <li> + <div + className="create-project-repository selected" + onClick={[Function]} + role="listitem" > - Awesome Project - </span> - </Checkbox> -</Fragment> + <div + className="flex-1 display-flex-center" + > + <Checkbox + checked={true} + disabled={false} + onCheck={[Function]} + thirdState={false} + /> + <img + alt="Foo Provider" + className="spacer-left" + height={14} + src="/images/sonarcloud/foo.svg" + width={14} + /> + <span + className="spacer-left" + > + Awesome Project + </span> + </div> + </div> + </li> +</Tooltip> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap index 8db84dbe8df..0f3247f2994 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should display the bounded organizations dropdown with the list of repositories 1`] = ` +exports[`should display the bound organizations dropdown with the remote repositories 1`] = ` <Fragment> <withRouter(OrganizationInput) autoImport={true} @@ -9,12 +9,16 @@ exports[`should display the bounded organizations dropdown with the list of repo organizations={ Array [ Object { + "actions": Object { + "admin": true, + }, "alm": Object { "key": "github", "url": "", }, "key": "foo", "name": "Foo", + "subscription": "FREE", }, Object { "alm": Object { @@ -23,6 +27,7 @@ exports[`should display the bounded organizations dropdown with the list of repo }, "key": "bar", "name": "Bar", + "subscription": "FREE", }, ] } @@ -37,8 +42,22 @@ exports[`should display the bounded organizations dropdown with the list of repo "name": "GitHub", } } + onOrganizationUpgrade={[MockFunction]} onProjectCreate={[MockFunction]} - organization="foo" + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "url": "", + }, + "key": "foo", + "name": "Foo", + "subscription": "FREE", + } + } /> </Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap index 8379a51721e..b265a69a05e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap @@ -1,114 +1,143 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should correctly create a project 1`] = ` -<SubmitButton - disabled={false} -> - setup -</SubmitButton> -`; - exports[`should display the list of repositories 1`] = ` -<DeferredSpinner - loading={true} - timeout={100} +<div + className="create-project" > - <form - onSubmit={[Function]} + <div + className="flex-1 huge-spacer-right" > - <div - className="form-field" - > - <ul /> - </div> - <SubmitButton - disabled={true} - > - setup - </SubmitButton> <DeferredSpinner - className="spacer-left" - loading={false} + loading={true} timeout={100} + > + <ul /> + </DeferredSpinner> + </div> + <div + className="huge-spacer-left" + > + <SetupProjectBox + onProjectCreate={[MockFunction]} + onProvisionFail={[Function]} + organization={ + Object { + "alm": Object { + "key": "github", + "url": "", + }, + "key": "sonarsource", + "name": "SonarSource", + "subscription": "FREE", + } + } + selectedRepositories={Array []} /> - </form> -</DeferredSpinner> + </div> +</div> `; exports[`should display the list of repositories 2`] = ` -<DeferredSpinner - loading={false} - timeout={100} +<div + className="create-project" > - <form - onSubmit={[Function]} + <div + className="flex-1 huge-spacer-right" > - <div - className="form-field" + <DeferredSpinner + loading={false} + timeout={100} > <ul> - <li - className="big-spacer-bottom" + <AlmRepositoryItem + disabled={false} + highlightUpgradeBox={[Function]} + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", + } + } key="github/cool" - > - <AlmRepositoryItem - identityProvider={ - Object { - "backgroundColor": "blue", - "iconPath": "icon/path", - "installationUrl": "https://alm.installation.url", - "key": "github", - "name": "GitHub", - } + repository={ + Object { + "installationKey": "github/cool", + "label": "Cool Project", + "linkedProjectKey": "proj_cool", + "linkedProjectName": "Proj Cool", } - repository={ - Object { - "installationKey": "github/cool", - "label": "Cool Project", - "linkedProjectKey": "proj_cool", - "linkedProjectName": "Proj Cool", - } + } + selected={false} + toggleRepository={[Function]} + /> + <AlmRepositoryItem + disabled={false} + highlightUpgradeBox={[Function]} + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", } - selected={false} - toggleRepository={[Function]} - /> - </li> - <li - className="big-spacer-bottom" + } key="github/awesome" - > - <AlmRepositoryItem - identityProvider={ - Object { - "backgroundColor": "blue", - "iconPath": "icon/path", - "installationUrl": "https://alm.installation.url", - "key": "github", - "name": "GitHub", - } + repository={ + Object { + "installationKey": "github/awesome", + "label": "Awesome Project", } - repository={ - Object { - "installationKey": "github/awesome", - "label": "Awesome Project", - } - } - selected={false} - toggleRepository={[Function]} - /> - </li> + } + selected={false} + toggleRepository={[Function]} + /> </ul> - </div> - <SubmitButton - disabled={true} - > - setup - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - timeout={100} + </DeferredSpinner> + </div> + <div + className="huge-spacer-left" + > + <SetupProjectBox + onProjectCreate={[MockFunction]} + onProvisionFail={[Function]} + organization={ + Object { + "alm": Object { + "key": "github", + "url": "", + }, + "key": "sonarsource", + "name": "SonarSource", + "subscription": "FREE", + } + } + selectedRepositories={Array []} /> - </form> -</DeferredSpinner> + </div> +</div> +`; + +exports[`should display the organization upgrade box 1`] = ` +<UpgradeOrganizationBox + className="" + onOrganizationUpgrade={[Function]} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "url": "", + }, + "key": "sonarsource", + "name": "SonarSource", + "subscription": "FREE", + } + } +/> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/SetupProjectBox-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/SetupProjectBox-test.tsx.snap new file mode 100644 index 00000000000..0606885275a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/SetupProjectBox-test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should correctly create projects 1`] = ` +<form + className="create-project-setup boxed-group open" + onSubmit={[Function]} +> + <div + className="boxed-group-header" + > + <h2 + className="spacer-top" + > + onboarding.create_project.x_repositories_selected.2 + </h2> + </div> + <div + className="boxed-group-inner" + > + <div + className="flex-1" + > + <p> + onboarding.create_project.1_repository_created_as_public + </p> + <p> + onboarding.create_project.1_repository_created_as_private + </p> + </div> + <div> + <SubmitButton + className="button-large" + disabled={false} + > + setup + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + timeout={100} + /> + </div> + </div> +</form> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/style.css b/server/sonar-web/src/main/js/apps/create/project/style.css new file mode 100644 index 00000000000..b1481cd6d13 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/style.css @@ -0,0 +1,104 @@ +/* + * 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. + */ +.create-project { + display: flex !important; + justify-content: space-between; +} + +.create-project-repository { + display: flex; + align-items: center; + min-width: 500px; + height: 40px; + border: 1px solid var(--barBorderColor); + padding: var(--gridSize) calc(var(--gridSize) * 2); + margin-bottom: calc(var(--gridSize)); + box-sizing: border-box; + cursor: pointer; + transition: all 0.3s ease; +} + +.create-project-repository.disabled { + background-color: var(--disableGrayBg); + border-color: var(--disableGrayBorder); + cursor: default; +} + +.create-project-repository.imported { + cursor: default; +} + +.create-project-repository.selected { + background-color: var(--lightBlue); + border-color: var(--darkBlue); +} + +.create-project-repository:not(.imported):not(.disabled):hover, +.create-project-repository:not(.imported):not(.disabled):focus, +.create-project-repository:not(.imported):not(.disabled):active { + border-color: var(--blue); + box-shadow: none; +} + +.create-project-setup { + display: flex; + overflow: hidden; + opacity: 0; + flex-direction: column; + height: 0; + width: 450px; + margin-bottom: 0; + color: #fff; + background-color: var(--sonarcloudBlue900); + border: none; + border-radius: 3px; + transition: height 0.5s ease, opacity 0.4s ease-out, margin-bottom 0.5s ease-in; +} + +.create-project-setup.open { + opacity: 1; + height: 160px; + margin-bottom: calc(2.5 * var(--gridSize)); +} + +.create-project-setup h2 { + color: #fff; + font-weight: 700; + font-size: var(--bigFontSize); +} + +.create-project-setup .boxed-group-inner { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.create-project-setup .button { + border-color: var(--sonarcloudBlue500); + background-color: var(--sonarcloudBlue500); + color: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +.create-project-setup .button:hover, +.create-project-setup .button:focus { + border-color: var(--sonarcloudBlue600); + background-color: var(--sonarcloudBlue600); +} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationJustCreated.css b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationJustCreated.css index 2405d972144..17bec1f2bca 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationJustCreated.css +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationJustCreated.css @@ -19,5 +19,5 @@ */ .organization-just-created { margin: 120px auto 0; - width: 480px; + width: 700px; } diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/App.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/App.tsx index 0f82247d151..748b4cbd025 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/App.tsx @@ -23,7 +23,7 @@ import { without } from 'lodash'; import AllHoldersList from './AllHoldersList'; import PageHeader from './PageHeader'; import PublicProjectDisclaimer from './PublicProjectDisclaimer'; -import UpgradeOrganizationBox from '../../../../components/common/UpgradeOrganizationBox'; +import UpgradeOrganizationBox from '../../../create/components/UpgradeOrganizationBox'; import VisibilitySelector from '../../../../components/common/VisibilitySelector'; import * as api from '../../../../api/permissions'; import { translate } from '../../../../helpers/l10n'; @@ -31,7 +31,9 @@ import '../../styles.css'; interface Props { component: T.Component; + fetchOrganization: (organization: string) => void; onComponentChange: (changes: Partial<T.Component>) => void; + organization?: T.Organization; } interface State { @@ -299,6 +301,16 @@ export default class App extends React.PureComponent<Props, State> { return Promise.resolve(); }; + handleOrganizationUpgrade = () => { + const { component, organization } = this.props; + if (organization) { + this.props.onComponentChange({ + configuration: { ...component.configuration, canUpdateProjectVisibilityToPrivate: true } + }); + this.props.fetchOrganization(organization.key); + } + }; + handleVisibilityChange = (visibility: string) => { if (visibility === 'public') { this.openDisclaimer(); @@ -348,16 +360,25 @@ export default class App extends React.PureComponent<Props, State> { }; render() { + const { component, organization } = this.props; const canTurnToPrivate = - this.props.component.configuration != null && - this.props.component.configuration.canUpdateProjectVisibilityToPrivate; + component.configuration && component.configuration.canUpdateProjectVisibilityToPrivate; + + let showUpgradeBox; + if (organization && !canTurnToPrivate) { + const isOrgAdmin = organization.actions && organization.actions.admin; + showUpgradeBox = + isOrgAdmin && + this.props.component.qualifier === 'TRK' && + !organization.canUpdateProjectsVisibilityToPrivate; + } return ( <div className="page page-limited" id="project-permissions-page"> <Helmet title={translate('permissions.page')} /> <PageHeader - component={this.props.component} + component={component} loadHolders={this.loadHolders} loading={this.state.loading} /> @@ -366,22 +387,26 @@ export default class App extends React.PureComponent<Props, State> { canTurnToPrivate={canTurnToPrivate} className="big-spacer-top big-spacer-bottom" onChange={this.handleVisibilityChange} - visibility={this.props.component.visibility} + visibility={component.visibility} /> - {this.props.component.qualifier === 'TRK' && - !canTurnToPrivate && ( - <UpgradeOrganizationBox organization={this.props.component.organization} /> + {showUpgradeBox && + organization && ( + <UpgradeOrganizationBox + className="big-spacer-bottom" + onOrganizationUpgrade={this.handleOrganizationUpgrade} + organization={organization} + /> )} {this.state.disclaimer && ( <PublicProjectDisclaimer - component={this.props.component} + component={component} onClose={this.closeDisclaimer} onConfirm={this.turnProjectToPublic} /> )} </div> <AllHoldersList - component={this.props.component} + component={component} filter={this.state.filter} grantPermissionToGroup={this.grantPermissionToGroup} grantPermissionToUser={this.grantPermissionToUser} @@ -397,7 +422,7 @@ export default class App extends React.PureComponent<Props, State> { selectedPermission={this.state.selectedPermission} users={this.state.users} usersPaging={this.state.usersPaging} - visibility={this.props.component.visibility} + visibility={component.visibility} /> </div> ); diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/AppContainer.ts b/server/sonar-web/src/main/js/apps/permissions/project/components/AppContainer.ts index b8f0073e5bb..ac9900803c7 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/AppContainer.ts +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/AppContainer.ts @@ -19,10 +19,22 @@ */ import { connect } from 'react-redux'; import App from './App'; -import { getCurrentUser, Store } from '../../../../store/rootReducer'; +import { getCurrentUser, getOrganizationByKey, Store } from '../../../../store/rootReducer'; +import { fetchOrganization } from '../../../organizations/actions'; -const mapStateToProps = (state: Store) => ({ - currentUser: getCurrentUser(state) +interface OwnProps { + component: T.Component; + onComponentChange: (changes: Partial<T.Component>) => void; +} + +const mapStateToProps = (state: Store, { component }: OwnProps) => ({ + currentUser: getCurrentUser(state), + organization: getOrganizationByKey(state, component.organization) }); -export default connect(mapStateToProps)(App); +const mapDispatchToProps = { fetchOrganization }; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(App); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.tsx index b8542ee8be4..bb553eea77b 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.tsx @@ -26,6 +26,7 @@ import GraphsTooltipsContentIssues from './GraphsTooltipsContentIssues'; import { DEFAULT_GRAPH, MeasureHistory, Serie } from '../utils'; import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; import { Popup, PopupPlacement } from '../../../components/ui/popups'; +import { isDefined } from '../../../helpers/types'; interface Props { events: T.AnalysisEvent[]; @@ -85,7 +86,7 @@ export default class GraphsTooltips extends React.PureComponent<Props> { left -= TOOLTIP_WIDTH; placement = PopupPlacement.LeftTop; } - const tooltipContent = this.renderContent().filter(Boolean); + const tooltipContent = this.renderContent().filter(isDefined); const addSeparator = tooltipContent.length > 0; return ( <Popup diff --git a/server/sonar-web/src/main/js/apps/projects/routes.ts b/server/sonar-web/src/main/js/apps/projects/routes.ts index 5478d372823..e73a375e5ae 100644 --- a/server/sonar-web/src/main/js/apps/projects/routes.ts +++ b/server/sonar-web/src/main/js/apps/projects/routes.ts @@ -24,6 +24,7 @@ import { PROJECTS_DEFAULT_FILTER, PROJECTS_ALL } from './utils'; import { save } from '../../helpers/storage'; import { isSonarCloud } from '../../helpers/system'; import { lazyLoad } from '../../components/lazyLoad'; +import { isDefined } from '../../helpers/types'; const routes = [ { indexRoute: { component: DefaultPageSelectorContainer } }, @@ -39,6 +40,6 @@ const routes = [ path: 'create', component: lazyLoad(() => import('../create/project/CreateProjectPage')) } -].filter(Boolean); +].filter(isDefined); export default routes; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx index 293e9f98836..83385c72f07 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx @@ -33,6 +33,7 @@ import { translate } from '../../helpers/l10n'; export interface Props { currentUser: { login: string }; hasProvisionPermission?: boolean; + onOrganizationUpgrade: () => void; onVisibilityChange: (visibility: T.Visibility) => void; organization: T.Organization; topLevelQualifiers: string[]; @@ -233,6 +234,7 @@ export default class App extends React.PureComponent<Props, State> { {this.state.createProjectForm && ( <CreateProjectForm onClose={this.closeCreateProjectForm} + onOrganizationUpgrade={this.props.onOrganizationUpgrade} onProjectCreated={this.requestProjects} organization={this.props.organization} /> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx index 99437f0da4f..35419fc25b6 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx @@ -51,6 +51,10 @@ class AppContainer extends React.PureComponent<OwnProps & StateProps & DispatchP } } + handleOrganizationUpgrade = () => { + this.props.fetchOrganization(this.props.organization.key); + }; + handleVisibilityChange = (visibility: T.Visibility) => { if (this.props.organization) { this.props.onVisibilityChange(this.props.organization, visibility); @@ -71,6 +75,7 @@ class AppContainer extends React.PureComponent<OwnProps & StateProps & DispatchP <App currentUser={this.props.currentUser} hasProvisionPermission={actions.provision} + onOrganizationUpgrade={this.handleOrganizationUpgrade} onVisibilityChange={this.handleVisibilityChange} organization={organization} topLevelQualifiers={topLevelQualifiers} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx index 04e41ea9b39..a035830d333 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx @@ -19,7 +19,6 @@ */ import * as React from 'react'; import * as classNames from 'classnames'; -import UpgradeOrganizationBox from '../../components/common/UpgradeOrganizationBox'; import Modal from '../../components/controls/Modal'; import { Button, ResetButtonLink } from '../../components/ui/buttons'; import { translate } from '../../helpers/l10n'; @@ -35,7 +34,7 @@ interface State { visibility: T.Visibility; } -export default class ChangeVisibilityForm extends React.PureComponent<Props, State> { +export default class ChangeDefaultVisibilityForm extends React.PureComponent<Props, State> { constructor(props: Props) { super(props); this.state = { visibility: props.organization.projectVisibility as T.Visibility }; @@ -95,12 +94,10 @@ export default class ChangeVisibilityForm extends React.PureComponent<Props, Sta </div> ))} - {organization.canUpdateProjectsVisibilityToPrivate ? ( + {organization.canUpdateProjectsVisibilityToPrivate && ( <Alert variant="warning"> {translate('organization.change_visibility_form.warning')} </Alert> - ) : ( - <UpgradeOrganizationBox organization={this.props.organization.key} /> )} </div> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx index e434616ecc1..133b5973a72 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import { Link } from 'react-router'; import { FormattedMessage } from 'react-intl'; import { createProject } from '../../api/components'; -import UpgradeOrganizationBox from '../../components/common/UpgradeOrganizationBox'; +import UpgradeOrganizationBox from '../create/components/UpgradeOrganizationBox'; import VisibilitySelector from '../../components/common/VisibilitySelector'; import Modal from '../../components/controls/Modal'; import { SubmitButton, ResetButtonLink } from '../../components/ui/buttons'; @@ -32,6 +32,7 @@ import { Alert } from '../../components/ui/Alert'; interface Props { onClose: () => void; onProjectCreated: () => void; + onOrganizationUpgrade: () => void; organization: T.Organization; } @@ -194,12 +195,18 @@ export default class CreateProjectForm extends React.PureComponent<Props, State> onChange={this.handleVisibilityChange} visibility={this.state.visibility} /> - {!organization.canUpdateProjectsVisibilityToPrivate && ( - <div className="spacer-top"> - <UpgradeOrganizationBox organization={organization.key} /> + </div> + {organization.actions && + organization.actions.admin && + !organization.canUpdateProjectsVisibilityToPrivate && ( + <div className="spacer-top display-flex-space-around"> + <UpgradeOrganizationBox + insideModal={true} + onOrganizationUpgrade={this.props.onOrganizationUpgrade} + organization={organization} + /> </div> )} - </div> </div> <footer className="modal-foot"> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx index 70d207a00c4..e5a75ac6911 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import ChangeVisibilityForm from './ChangeVisibilityForm'; +import ChangeDefaultVisibilityForm from './ChangeDefaultVisibilityForm'; import { EditButton, Button } from '../../components/ui/buttons'; import { translate } from '../../helpers/l10n'; import { isSonarCloud } from '../../helpers/system'; @@ -75,13 +75,14 @@ export default class Header extends React.PureComponent<Props, State> { <p className="page-description">{translate('projects_management.page.description')}</p> - {this.state.visibilityForm && ( - <ChangeVisibilityForm - onClose={this.closeVisiblityForm} - onConfirm={this.props.onVisibilityChange} - organization={organization} - /> - )} + {!isSonarCloud() && + this.state.visibilityForm && ( + <ChangeDefaultVisibilityForm + onClose={this.closeVisiblityForm} + onConfirm={this.props.onVisibilityChange} + organization={organization} + /> + )} </header> ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx index f27f9b4eaf3..3a51882532d 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx @@ -137,6 +137,7 @@ function shallowRender(props?: { [P in keyof Props]?: Props[P] }) { <App currentUser={{ login: 'foo' }} hasProvisionPermission={true} + onOrganizationUpgrade={jest.fn()} onVisibilityChange={jest.fn()} organization={organization} topLevelQualifiers={['TRK', 'VW', 'APP']} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeDefaultVisibilityForm-test.tsx index d6f02e43602..75fd4e85a7a 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeDefaultVisibilityForm-test.tsx @@ -19,7 +19,7 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import ChangeVisibilityForm from '../ChangeVisibilityForm'; +import ChangeDefaultVisibilityForm from '../ChangeDefaultVisibilityForm'; import { click } from '../../../helpers/testUtils'; const organization: T.Organization = { @@ -61,9 +61,9 @@ it('changes visibility', () => { expect(onConfirm).toBeCalledWith('private'); }); -function shallowRender(props: Partial<ChangeVisibilityForm['props']> = {}) { +function shallowRender(props: Partial<ChangeDefaultVisibilityForm['props']> = {}) { return shallow( - <ChangeVisibilityForm + <ChangeDefaultVisibilityForm onClose={jest.fn()} onConfirm={jest.fn()} organization={organization} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx index 4fb4271cbff..010a64e4845 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx @@ -31,12 +31,18 @@ import { change, submit, waitAndUpdate } from '../../../helpers/testUtils'; const createProject = require('../../../api/components').createProject as jest.Mock<any>; -const organization: T.Organization = { key: 'org', name: 'org', projectVisibility: 'public' }; +const organization: T.Organization = { + actions: { admin: true }, + key: 'org', + name: 'org', + projectVisibility: 'public' +}; it('creates project', async () => { const wrapper = shallow( <CreateProjectForm onClose={jest.fn()} + onOrganizationUpgrade={jest.fn()} onProjectCreated={jest.fn()} organization={organization} /> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx index 6eddf971a9f..aae7beee4ed 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx @@ -22,7 +22,13 @@ import { shallow } from 'enzyme'; import Header, { Props } from '../Header'; import { click } from '../../../helpers/testUtils'; -const organization: T.Organization = { key: 'org', name: 'org', projectVisibility: 'public' }; +jest.mock('../../../helpers/system', () => ({ isSonarCloud: jest.fn().mockReturnValue(false) })); + +const organization: T.Organization = { + key: 'org', + name: 'org', + projectVisibility: 'public' +}; it('renders', () => { expect(shallowRender()).toMatchSnapshot(); @@ -41,14 +47,14 @@ it('changes default visibility', () => { click(wrapper.find('.js-change-visibility')); - const modalWrapper = wrapper.find('ChangeVisibilityForm'); + const modalWrapper = wrapper.find('ChangeDefaultVisibilityForm'); expect(modalWrapper).toMatchSnapshot(); modalWrapper.prop<Function>('onConfirm')('private'); expect(onVisibilityChange).toBeCalledWith('private'); modalWrapper.prop<Function>('onClose')(); wrapper.update(); - expect(wrapper.find('ChangeVisibilityForm').exists()).toBeFalsy(); + expect(wrapper.find('ChangeDefaultVisibilityForm').exists()).toBeFalsy(); }); function shallowRender(props?: { [P in keyof Props]?: Props[P] }) { diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeDefaultVisibilityForm-test.tsx.snap index 0c5b0d3ed6b..a80e794b95a 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeDefaultVisibilityForm-test.tsx.snap @@ -260,9 +260,6 @@ exports[`renders disabled 1`] = ` visibility.private.description.short </p> </div> - <UpgradeOrganizationBox - organization="org" - /> </div> <footer className="modal-foot" diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap index a7ce31e715c..859052104d3 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap @@ -77,13 +77,24 @@ exports[`creates project 1`] = ` onChange={[Function]} visibility="public" /> - <div - className="spacer-top" - > - <UpgradeOrganizationBox - organization="org" - /> - </div> + </div> + <div + className="spacer-top display-flex-space-around" + > + <UpgradeOrganizationBox + insideModal={true} + onOrganizationUpgrade={[MockFunction]} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "key": "org", + "name": "org", + "projectVisibility": "public", + } + } + /> </div> </div> <footer @@ -183,13 +194,24 @@ exports[`creates project 2`] = ` onChange={[Function]} visibility="private" /> - <div - className="spacer-top" - > - <UpgradeOrganizationBox - organization="org" - /> - </div> + </div> + <div + className="spacer-top display-flex-space-around" + > + <UpgradeOrganizationBox + insideModal={true} + onOrganizationUpgrade={[MockFunction]} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "key": "org", + "name": "org", + "projectVisibility": "public", + } + } + /> </div> </div> <footer @@ -289,13 +311,24 @@ exports[`creates project 3`] = ` onChange={[Function]} visibility="private" /> - <div - className="spacer-top" - > - <UpgradeOrganizationBox - organization="org" - /> - </div> + </div> + <div + className="spacer-top display-flex-space-around" + > + <UpgradeOrganizationBox + insideModal={true} + onOrganizationUpgrade={[MockFunction]} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "key": "org", + "name": "org", + "projectVisibility": "public", + } + } + /> </div> </div> <footer diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap index 456011bf7c6..fba62f83057 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`changes default visibility 1`] = ` -<ChangeVisibilityForm +<ChangeDefaultVisibilityForm onClose={[Function]} onConfirm={[MockFunction]} organization={ diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx index 6805ac22c76..1df6e78e993 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx @@ -63,7 +63,8 @@ export class OnboardingModal extends React.PureComponent<Props> { contentLabel={header} medium={true} onRequestClose={this.props.onClose} - shouldCloseOnOverlayClick={false}> + shouldCloseOnOverlayClick={false} + simple={true}> <div className="modal-simple-head text-center"> <h1>{translate('onboarding.header')}</h1> <p className="spacer-top">{translate('onboarding.header.description')}</p> @@ -80,7 +81,7 @@ export class OnboardingModal extends React.PureComponent<Props> { </h6> </Button> </div> - <div className="modal-simple-footer text-center"> + <div className="modal-simple-foot text-center"> <ResetButtonLink className="spacer-bottom" onClick={this.props.onClose}> {translate('not_now')} </ResetButtonLink> diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap index 1b5da9e7124..423382e72c1 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap @@ -6,6 +6,7 @@ exports[`renders correctly 1`] = ` medium={true} onRequestClose={[MockFunction]} shouldCloseOnOverlayClick={false} + simple={true} > <div className="modal-simple-head text-center" @@ -50,7 +51,7 @@ exports[`renders correctly 1`] = ` </Button> </div> <div - className="modal-simple-footer text-center" + className="modal-simple-foot text-center" > <ResetButtonLink className="spacer-bottom" diff --git a/server/sonar-web/src/main/js/apps/tutorials/styles.css b/server/sonar-web/src/main/js/apps/tutorials/styles.css index 41fbedac1b5..ae75cc4bca2 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/styles.css +++ b/server/sonar-web/src/main/js/apps/tutorials/styles.css @@ -64,6 +64,7 @@ justify-content: space-around; padding: 44px 100px; background-color: var(--barBackgroundColor); + margin-top: var(--pagePadding); } .onboarding-choice { diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.tsx b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.tsx index e49532d7fe0..b985e38a3f8 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.tsx +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.tsx @@ -25,6 +25,7 @@ import { scaleLinear, scalePoint, scaleTime, ScaleTime } from 'd3-scale'; import { line as d3Line, area, curveBasis } from 'd3-shape'; import * as theme from '../../app/theme'; import { Serie, Point } from '../../apps/projectActivity/utils'; +import { isDefined } from '../../helpers/types'; import './LineChart.css'; import './AdvancedTimeline.css'; @@ -412,7 +413,7 @@ export default class AdvancedTimeline extends React.PureComponent<Props, State> /> ); }) - .filter(Boolean) + .filter(isDefined) ) .filter(dots => dots.length > 0)} </g> diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx index 8d5b8c4f1ca..903d1323345 100644 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx +++ b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx @@ -77,7 +77,7 @@ export class Bubble<T> extends React.PureComponent<BubbleProps<T>> { } } -export interface BubbleItem<T> { +interface BubbleItem<T> { color?: string; key?: string; link?: string | Location; diff --git a/server/sonar-web/src/main/js/components/charts/TreeMap.tsx b/server/sonar-web/src/main/js/components/charts/TreeMap.tsx index 5116b3b726e..f5f94e7611c 100644 --- a/server/sonar-web/src/main/js/components/charts/TreeMap.tsx +++ b/server/sonar-web/src/main/js/components/charts/TreeMap.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import { treemap as d3Treemap, hierarchy as d3Hierarchy } from 'd3-hierarchy'; import TreeMapRect from './TreeMapRect'; import { translate } from '../../helpers/l10n'; +import { Location } from '../../helpers/urls'; import './TreeMap.css'; export interface TreeMapItem { diff --git a/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx b/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx index 3c3341028b5..1149b46903d 100644 --- a/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx +++ b/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx @@ -23,6 +23,7 @@ import * as classNames from 'classnames'; import { scaleLinear } from 'd3-scale'; import LinkIcon from '../icons-components/LinkIcon'; import Tooltip, { Placement } from '../controls/Tooltip'; +import { Location } from '../../helpers/urls'; const SIZE_SCALE = scaleLinear() .domain([3, 15]) diff --git a/server/sonar-web/src/main/js/components/controls/Modal.tsx b/server/sonar-web/src/main/js/components/controls/Modal.tsx index d57ba7d4ea1..14d5ea0de7c 100644 --- a/server/sonar-web/src/main/js/components/controls/Modal.tsx +++ b/server/sonar-web/src/main/js/components/controls/Modal.tsx @@ -25,7 +25,9 @@ ReactModal.setAppElement('#content'); interface OwnProps { medium?: boolean; + noBackdrop?: boolean; large?: boolean; + simple?: true; } type MandatoryProps = Pick<ReactModal.Props, 'contentLabel'>; @@ -35,9 +37,13 @@ type Props = Partial<ReactModal.Props> & MandatoryProps & OwnProps; export default function Modal(props: Props) { return ( <ReactModal - className={classNames('modal', { 'modal-medium': props.medium, 'modal-large': props.large })} + className={classNames('modal', { + 'modal-medium': props.medium, + 'modal-large': props.large, + 'modal-simple': props.simple + })} isOpen={true} - overlayClassName="modal-overlay" + overlayClassName={classNames('modal-overlay', { 'modal-no-backdrop': props.noBackdrop })} {...props} /> ); diff --git a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx index 802be846f8b..529cb1ed269 100644 --- a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx +++ b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx @@ -207,13 +207,15 @@ export class TooltipInner extends React.Component<Props, State> { handleMouseEnter = () => { this.mouseEnterTimeout = window.setTimeout(() => { - if (this.mounted) { - // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers - // to workaround this issue, check that its value is not `undefined` - // (if it's `undefined`, it means the timer has been reset) - if (this.props.visible === undefined && this.mouseEnterTimeout !== undefined) { - this.setState({ visible: true }); - } + // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers + // to workaround this issue, check that its value is not `undefined` + // (if it's `undefined`, it means the timer has been reset) + if ( + this.mounted && + this.props.visible === undefined && + this.mouseEnterTimeout !== undefined + ) { + this.setState({ visible: true }); } }, (this.props.mouseEnterDelay || 0) * 1000); @@ -230,10 +232,8 @@ export class TooltipInner extends React.Component<Props, State> { if (!this.mouseIn) { this.mouseLeaveTimeout = window.setTimeout(() => { - if (this.mounted) { - if (this.props.visible === undefined && !this.mouseIn) { - this.setState({ visible: false }); - } + if (this.mounted && this.props.visible === undefined && !this.mouseIn) { + this.setState({ visible: false }); } }, (this.props.mouseLeaveDelay || 0) * 1000); @@ -254,7 +254,6 @@ export class TooltipInner extends React.Component<Props, State> { render() { const { classNameSpace = 'tooltip' } = this.props; - return ( <> {React.cloneElement(this.props.children, { diff --git a/server/sonar-web/src/main/js/components/controls/react-select.css b/server/sonar-web/src/main/js/components/controls/react-select.css index ac5829d0daf..dae9dbd8cd8 100644 --- a/server/sonar-web/src/main/js/components/controls/react-select.css +++ b/server/sonar-web/src/main/js/components/controls/react-select.css @@ -90,6 +90,10 @@ border-color: var(--blue); } +.Select-placeholder { + color: var(--secondFontColor); +} + .Select-placeholder, :not(.Select--multi) > .Select-control .Select-value { bottom: 0; @@ -115,6 +119,13 @@ padding-top: 4px; } +.Select-value .outline-badge, +.Select-option .outline-badge { + height: 20px; + line-height: 19px; + margin-top: 1px; +} + .Select-option svg, .Select-option img, .Select-option [class^='icon-'] { diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx index 637d54e55ea..c6cfd29cb57 100644 --- a/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx +++ b/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx @@ -24,7 +24,10 @@ import { withUserOrganizations } from '../withUserOrganizations'; jest.mock('../../../api/organizations', () => ({ getOrganizations: jest.fn() })); -class X extends React.Component<{ userOrganizations: T.Organization[] }> { +class X extends React.Component<{ + fetchMyOrganizations: () => Promise<void>; + userOrganizations: T.Organization[]; +}> { render() { return <div />; } diff --git a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx index 485c9b6ac96..e09189b3799 100644 --- a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx +++ b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx @@ -22,21 +22,17 @@ import { connect } from 'react-redux'; import { Store, getMyOrganizations } from '../../store/rootReducer'; import { fetchMyOrganizations } from '../../apps/account/organizations/actions'; +interface OwnProps { + fetchMyOrganizations: () => Promise<void>; + userOrganizations: T.Organization[]; +} + export function withUserOrganizations<P>( - WrappedComponent: React.ComponentClass< - P & { - personalOrganization?: T.Organization; - userOrganizations: T.Organization[]; - } - > + WrappedComponent: React.ComponentClass<P & Partial<OwnProps>> ) { - type Props = P & { - fetchMyOrganizations: () => Promise<void>; - userOrganizations: T.Organization[]; - }; const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; - class Wrapper extends React.Component<Props> { + class Wrapper extends React.Component<P & OwnProps> { static displayName = `withUserOrganizations(${wrappedDisplayName})`; componentDidMount() { @@ -44,13 +40,11 @@ export function withUserOrganizations<P>( } render() { - // @ts-ignore Rest operator not supported yet by TS for generics - const { fetchMyOrganizations, ...other } = this.props; - return <WrappedComponent {...other} />; + return <WrappedComponent {...this.props} />; } } - const mapDispatchToProps = { fetchMyOrganizations }; + const mapDispatchToProps = { fetchMyOrganizations: fetchMyOrganizations as any }; function mapStateToProps(state: Store) { return { userOrganizations: getMyOrganizations(state) }; diff --git a/server/sonar-web/src/main/js/components/tags/TagsList.css b/server/sonar-web/src/main/js/components/tags/TagsList.css index a93001c80b7..9168d930510 100644 --- a/server/sonar-web/src/main/js/components/tags/TagsList.css +++ b/server/sonar-web/src/main/js/components/tags/TagsList.css @@ -19,6 +19,7 @@ */ .tags-list { white-space: nowrap; + line-height: 16px; } .tags-list i::before { diff --git a/server/sonar-web/src/main/js/components/ui/buttons.css b/server/sonar-web/src/main/js/components/ui/buttons.css index ed7a6499ecb..d7e7a0e37cc 100644 --- a/server/sonar-web/src/main/js/components/ui/buttons.css +++ b/server/sonar-web/src/main/js/components/ui/buttons.css @@ -163,6 +163,12 @@ font-size: 11px; } +.button-large { + height: var(--largeControlHeight); + padding: 0 16px; + font-size: var(--mediumFontSize); +} + /* #region .button-group */ .button-group { display: inline-block; diff --git a/server/sonar-web/src/main/js/components/common/UpgradeOrganizationBox.css b/server/sonar-web/src/main/js/helpers/types.ts index fc9e3c38ace..205e3f2094a 100644 --- a/server/sonar-web/src/main/js/components/common/UpgradeOrganizationBox.css +++ b/server/sonar-web/src/main/js/helpers/types.ts @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -.upgrade-organization-box { - max-width: 400px; - background-color: var(--barBackgroundColor) !important; + +export function isDefined<T>(x: T | undefined | null): x is T { + return x !== undefined && x !== null; } diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index ea32be1e07b..da207482207 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2740,10 +2740,16 @@ onboarding.project_analysis.suggestions.github=If you are using Travis CI, the S onboarding.create_project.header=Analyze projects onboarding.create_project.setup_manually=Setup manually -onboarding.create_project.create_new_org=I want to create another organization -onboarding.create_project.import_new_org=I want to import another organization +onboarding.create_project.create_new_org=Create another organization +onboarding.create_project.import_new_org=Import another organization onboarding.create_project.install_app_description.bitbucket=We need you to install the SonarCloud Bitbucket application on one of your team in order to select which repositories you want to analyze. onboarding.create_project.install_app_description.github=We need you to install the SonarCloud GitHub application on one of your organization in order to select which repositories you want to analyze. +onboarding.create_project.1_repository_selected=1 repository selected +onboarding.create_project.x_repositories_selected={0} repositories selected +onboarding.create_project.1_repository_created_as_public=1 repository will be created as a public project on SonarCloud +onboarding.create_project.x_repository_created_as_public={0} repositories will be created as public projects on SonarCloud +onboarding.create_project.1_repository_created_as_private=1 repository will be created as a private project on SonarCloud +onboarding.create_project.x_repository_created_as_private={0} repositories will be created as private projects on SonarCloud onboarding.create_project.organization=Organization onboarding.create_project.project_key=Project key onboarding.create_project.project_key.description=Up to 400 characters. All letters, digits, dash, underscore, point or colon. @@ -2756,9 +2762,11 @@ onboarding.create_project.display_name.description=Up to 500 characters onboarding.create_project.repository_imported=Already imported: {link} onboarding.create_project.see_project=See the project onboarding.create_project.select_repositories=Select repositories +onboarding.create_project.subscribe_to_import_private_repositories=You need to subscribe your organization to a paid plan to import private projects +onboarding.create_project.subscribtion_success_x={0} has been successfully upgraded to paid plan. You can now import and analyze private projects. onboarding.create_organization.page.header=Create Organization -onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects.{break}To analyze a private project you must subscribe your organization to a paid plan. From {price} a month. {more} +onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects. onboarding.create_organization.organization_name=Key onboarding.create_organization.organization_name.description=Up to 255 characters. All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). The display name can be specified in the additional info. onboarding.create_organization.organization_name.error=The provided value doesn't match the expected format. @@ -2806,13 +2814,13 @@ onboarding.import_organization.github=Import from GitHub organizations onboarding.import_organization.bind_existing=Bind to an existing SonarCloud organization onboarding.import_organization.create_new=Create new SonarCloud organization from it onboarding.import_organization.already_bound_x=Your organization {avatar} {name} is already bound to the SonarCloud organization {boundAvatar} {boundName}. Try again and choose a different organization. -onboarding.import_organization_x=Import {avatar} {name} into SonarCloud organization +onboarding.import_organization_x=Import {avatar} {name} into a SonarCloud organization onboarding.import_personal_organization_x=Bind {avatar} {name} with your personal SonarCloud organization {personalAvatar} {personalName} onboarding.team.header=Join a team onboarding.team.first_step=Well congrats, the first step is done! -onboarding.team.how_to_join=To join a team, the only thing you need to do is to be a user registered on Sonarcloud. The administrator of the Sonarcloud organization you wish to join has to add you to his organization's members {link}. Ask him to do so! +onboarding.team.how_to_join=To join a team, the only thing you need to do is to be a user registered on Sonarcloud. The administrator of the Sonarcloud organization you wish to join has to add you to their organization's members {link}. Ask them to do so! onboarding.team.work_in_progress=We are currently working on a better way to join a team or invite people to yours. onboarding.analyze_your_code.note=Free |