]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-183 Advertise paid plan and recommend it to users
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 23 Nov 2018 15:41:46 +0000 (16:41 +0100)
committerSonarTech <sonartech@sonarsource.com>
Fri, 7 Dec 2018 19:21:05 +0000 (20:21 +0100)
25 files changed:
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/apps/create/components/CardPlan.css [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
server/sonar-web/src/main/js/apps/create/organization/AutoPersonalOrganizationBind.tsx
server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoPersonalOrganizationBind-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanSelect-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/RemoteOrganizationChoose-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoPersonalOrganizationBind-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanSelect-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/PlanStep-test.tsx.snap
server/sonar-web/src/main/js/components/icons-components/RecommendedIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index aad1cc73b48323958619204a4fd7f2b201744f20..c7ab0e8a71c15e97826e6354d9e3a65c29e36faf 100644 (file)
@@ -29,6 +29,8 @@ declare namespace T {
   export interface AlmOrganization extends OrganizationBase {
     key: string;
     personal: boolean;
+    privateRepos: number;
+    publicRepos: number;
   }
 
   export interface AlmRepository {
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
new file mode 100644 (file)
index 0000000..7b9f04e
--- /dev/null
@@ -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.
+ */
+
+.card-plan {
+  display: flex;
+  flex-direction: column;
+  width: 450px;
+  height: 210px;
+  background-color: #fff;
+  border: solid 1px var(--barBorderColor);
+  border-radius: 3px;
+  box-sizing: border-box;
+  margin-right: calc(2 * var(--gridSize));
+}
+
+.card-plan:last-child {
+  margin-right: 0;
+}
+
+.card-plan-actionable {
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.card-plan-actionable:focus {
+  outline: none;
+}
+
+.card-plan-actionable:not(.disabled):hover {
+  box-shadow: var(--defaultShadow);
+  transform: translateY(-2px);
+}
+
+.card-plan-actionable.selected {
+  border-color: var(--darkBlue);
+}
+
+.card-plan-actionable.disabled {
+  cursor: not-allowed;
+  background-color: var(--disableGrayBg);
+  border-color: var(--disableGrayBorder);
+}
+
+.card-plan-actionable.disabled h2,
+.card-plan-actionable.disabled ul {
+  color: var(--disableGrayText);
+}
+
+.card-plan-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) 0;
+}
+
+.card-plan-price {
+  font-size: var(--bigFontSize);
+}
+
+.card-plan-body {
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  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;
+}
+
+.card-plan-recommended {
+  position: relative;
+  padding: 6px calc(var(--gridSize) * 2);
+  left: -1px;
+  bottom: -1px;
+  width: 450px;
+  color: #fff;
+  background-color: var(--darkBlue);
+  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
new file mode 100644 (file)
index 0000000..937d470
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * 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 { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import CheckIcon from '../../../components/icons-components/CheckIcon';
+import RecommendedIcon from '../../../components/icons-components/RecommendedIcon';
+import { Alert } from '../../../components/ui/Alert';
+import { formatPrice } from '../organization/utils';
+import { translate } from '../../../helpers/l10n';
+import * as theme from '../../../app/theme';
+import './CardPlan.css';
+
+interface Props {
+  className?: string;
+  disabled?: boolean;
+  onClick?: () => void;
+  selected?: boolean;
+  startingPrice?: string;
+}
+
+interface CardProps extends Props {
+  children: React.ReactNode;
+  recommended?: string;
+  title: string;
+}
+
+export default function CardPlan(props: CardProps) {
+  const { className, disabled, onClick, recommended, selected, startingPrice } = props;
+  const isActionable = Boolean(onClick);
+  return (
+    <div
+      aria-checked={selected}
+      className={classNames(
+        'card-plan',
+        { 'card-plan-actionable': isActionable, disabled, selected },
+        className
+      )}
+      onClick={isActionable && !disabled ? onClick : undefined}
+      role="radio"
+      tabIndex={0}>
+      <h2 className="card-plan-header big-spacer-bottom">
+        <span className="display-flex-center">
+          {isActionable && (
+            <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': selected })} />
+          )}
+          {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>
+        )}
+      </h2>
+      <div className="card-plan-body">{props.children}</div>
+      {recommended && (
+        <div className="card-plan-recommended">
+          <RecommendedIcon className="spacer-right" />
+          <FormattedMessage
+            defaultMessage={recommended}
+            id={recommended}
+            values={{ recommended: <strong>{translate('recommended')}</strong> }}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface FreeProps extends Props {
+  almName?: string;
+  hasWarning: boolean;
+}
+
+export function FreeCardPlan({ almName, hasWarning, ...props }: FreeProps) {
+  const showInfo = almName && props.disabled;
+  const showWarning = almName && hasWarning && !props.disabled;
+
+  return (
+    <CardPlan 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>
+        {showWarning && (
+          <Alert variant="warning">
+            <FormattedMessage
+              defaultMessage={translate('billing.free_plan.private_repo_warning')}
+              id="billing.free_plan.private_repo_warning"
+              values={{ alm: almName }}
+            />
+          </Alert>
+        )}
+        {showInfo && (
+          <Alert variant="info">
+            <FormattedMessage
+              defaultMessage={translate('billing.free_plan.not_available_info')}
+              id="billing.free_plan.not_available_info"
+              values={{ alm: almName }}
+            />
+          </Alert>
+        )}
+      </>
+    </CardPlan>
+  );
+}
+
+interface PaidProps extends Props {
+  isRecommended: boolean;
+}
+
+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'),
+    translate('billing.upgrade_box.free_trial')
+  ];
+
+  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.green} />
+              {text}
+            </li>
+          ))}
+        </ul>
+        <div className="big-spacer-left">
+          <Link className="spacer-left" target="_blank" to="/documentation/sonarcloud-pricing/">
+            {translate('learn_more')}
+          </Link>
+        </div>
+      </>
+    </CardPlan>
+  );
+}
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
new file mode 100644 (file)
index 0000000..2190a50
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * 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 CardPlan, { FreeCardPlan, PaidCardPlan } from '../CardPlan';
+import { click } from '../../../../helpers/testUtils';
+
+it('should render correctly', () => {
+  expect(
+    shallow(
+      <CardPlan recommended="Recommended for you" startingPrice="$10" title="Paid Plan">
+        <div>content</div>
+      </CardPlan>
+    )
+  ).toMatchSnapshot();
+});
+
+it('should be actionable', () => {
+  const onClick = jest.fn();
+  const wrapper = shallow(
+    <CardPlan onClick={onClick} title="Free Plan">
+      <div>content</div>
+    </CardPlan>
+  );
+
+  expect(wrapper).toMatchSnapshot();
+  click(wrapper);
+  wrapper.setProps({ selected: true });
+  expect(wrapper).toMatchSnapshot();
+});
+
+describe('#FreeCardPlan', () => {
+  it('should render correctly', () => {
+    expect(shallow(<FreeCardPlan hasWarning={false} />)).toMatchSnapshot();
+  });
+
+  it('should render with warning', () => {
+    expect(
+      shallow(<FreeCardPlan almName="GitHub" hasWarning={true} selected={true} />)
+    ).toMatchSnapshot();
+  });
+
+  it('should render disabled with info', () => {
+    expect(
+      shallow(<FreeCardPlan almName="GitHub" disabled={true} hasWarning={false} />)
+    ).toMatchSnapshot();
+  });
+});
+
+describe('#PaidCardPlan', () => {
+  it('should render correctly', () => {
+    expect(shallow(<PaidCardPlan isRecommended={true} startingPrice="$10" />)).toMatchSnapshot();
+  });
+});
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
new file mode 100644 (file)
index 0000000..8de24b3
--- /dev/null
@@ -0,0 +1,294 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`#FreeCardPlan should render correctly 1`] = `
+<CardPlan
+  title="billing.free_plan.title"
+>
+  <ul
+    className="note"
+  >
+    <li>
+      billing.free_plan.all_projects_analyzed_public
+    </li>
+    <li>
+      billing.free_plan.anyone_can_browse_source_code
+    </li>
+  </ul>
+</CardPlan>
+`;
+
+exports[`#FreeCardPlan should render disabled with info 1`] = `
+<CardPlan
+  disabled={true}
+  title="billing.free_plan.title"
+>
+  <ul
+    className="note"
+  >
+    <li>
+      billing.free_plan.all_projects_analyzed_public
+    </li>
+    <li>
+      billing.free_plan.anyone_can_browse_source_code
+    </li>
+  </ul>
+  <Alert
+    variant="info"
+  >
+    <FormattedMessage
+      defaultMessage="billing.free_plan.not_available_info"
+      id="billing.free_plan.not_available_info"
+      values={
+        Object {
+          "alm": "GitHub",
+        }
+      }
+    />
+  </Alert>
+</CardPlan>
+`;
+
+exports[`#FreeCardPlan should render with warning 1`] = `
+<CardPlan
+  selected={true}
+  title="billing.free_plan.title"
+>
+  <ul
+    className="note"
+  >
+    <li>
+      billing.free_plan.all_projects_analyzed_public
+    </li>
+    <li>
+      billing.free_plan.anyone_can_browse_source_code
+    </li>
+  </ul>
+  <Alert
+    variant="warning"
+  >
+    <FormattedMessage
+      defaultMessage="billing.free_plan.private_repo_warning"
+      id="billing.free_plan.private_repo_warning"
+      values={
+        Object {
+          "alm": "GitHub",
+        }
+      }
+    />
+  </Alert>
+</CardPlan>
+`;
+
+exports[`#PaidCardPlan should render correctly 1`] = `
+<CardPlan
+  recommended="billing.paid_plan.recommended"
+  startingPrice="$10"
+  title="billing.paid_plan.title"
+>
+  <ul
+    className="note"
+  >
+    <li
+      className="display-flex-center"
+      key="0"
+    >
+      <CheckIcon
+        className="spacer-right"
+        fill="#00aa00"
+      />
+      billing.upgrade_box.unlimited_private_projects
+    </li>
+    <li
+      className="display-flex-center"
+      key="1"
+    >
+      <CheckIcon
+        className="spacer-right"
+        fill="#00aa00"
+      />
+      billing.upgrade_box.strict_control_private_data
+    </li>
+    <li
+      className="display-flex-center"
+      key="2"
+    >
+      <CheckIcon
+        className="spacer-right"
+        fill="#00aa00"
+      />
+      billing.upgrade_box.cancel_anytime
+    </li>
+    <li
+      className="display-flex-center"
+      key="3"
+    >
+      <CheckIcon
+        className="spacer-right"
+        fill="#00aa00"
+      />
+      billing.upgrade_box.free_trial
+    </li>
+  </ul>
+  <div
+    className="big-spacer-left"
+  >
+    <Link
+      className="spacer-left"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      target="_blank"
+      to="/documentation/sonarcloud-pricing/"
+    >
+      learn_more
+    </Link>
+  </div>
+</CardPlan>
+`;
+
+exports[`should be actionable 1`] = `
+<div
+  className="card-plan card-plan-actionable"
+  onClick={[MockFunction]}
+  role="radio"
+  tabIndex={0}
+>
+  <h2
+    className="card-plan-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      <i
+        className="icon-radio spacer-right"
+      />
+      Free Plan
+    </span>
+    <span
+      className="card-plan-price"
+    >
+      billing.price_format.0
+    </span>
+  </h2>
+  <div
+    className="card-plan-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should be actionable 2`] = `
+<div
+  aria-checked={true}
+  className="card-plan card-plan-actionable selected"
+  onClick={
+    [MockFunction] {
+      "calls": Array [
+        Array [
+          Object {
+            "currentTarget": Object {
+              "blur": [Function],
+            },
+            "preventDefault": [Function],
+            "stopPropagation": [Function],
+            "target": Object {
+              "blur": [Function],
+            },
+          },
+        ],
+      ],
+      "results": Array [
+        Object {
+          "isThrow": false,
+          "value": undefined,
+        },
+      ],
+    }
+  }
+  role="radio"
+  tabIndex={0}
+>
+  <h2
+    className="card-plan-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      <i
+        className="icon-radio spacer-right is-checked"
+      />
+      Free Plan
+    </span>
+    <span
+      className="card-plan-price"
+    >
+      billing.price_format.0
+    </span>
+  </h2>
+  <div
+    className="card-plan-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+  className="card-plan"
+  role="radio"
+  tabIndex={0}
+>
+  <h2
+    className="card-plan-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      Paid Plan
+    </span>
+    <FormattedMessage
+      defaultMessage="billing.price_from_x"
+      id="billing.price_from_x"
+      values={
+        Object {
+          "price": <span
+            className="card-plan-price"
+          >
+            $10
+          </span>,
+        }
+      }
+    />
+  </h2>
+  <div
+    className="card-plan-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+  <div
+    className="card-plan-recommended"
+  >
+    <RecommendedIcon
+      className="spacer-right"
+    />
+    <FormattedMessage
+      defaultMessage="Recommended for you"
+      id="Recommended for you"
+      values={
+        Object {
+          "recommended": <strong>
+            recommended
+          </strong>,
+        }
+      }
+    />
+  </div>
+</div>
+`;
index 85627bed7caffcaf537065b5aa05c4704236f00c..11f6fe3fbc5fbf08ce566462eb25e12c8671818d 100644 (file)
@@ -172,10 +172,11 @@ export default class AutoOrganizationCreate extends React.PureComponent<Props, S
         {subscriptionPlans !== undefined &&
           filter !== Filters.Bind && (
             <PlanStep
+              almApplication={this.props.almApplication}
+              almOrganization={this.props.almOrganization}
               createOrganization={this.handleCreateOrganization}
               onDone={this.props.onDone}
               onUpgradeFail={this.props.onUpgradeFail}
-              onlyPaid={false /* TODO */}
               open={step === Step.Plan}
               subscriptionPlans={subscriptionPlans}
             />
index fb903669336026a8472db6c9fee16866c5d6cafa..ec5942f392578be87d6666faa94ea3b56c8e2aec 100644 (file)
@@ -108,9 +108,10 @@ export default class AutoPersonalOrganizationBind extends React.PureComponent<Pr
         </OrganizationDetailsStep>
         {subscriptionPlans !== undefined && (
           <PlanStep
+            almApplication={this.props.almApplication}
+            almOrganization={this.props.almOrganization}
             createOrganization={this.handleCreateOrganization}
             onDone={this.props.onDone}
-            onlyPaid={false /* TODO */}
             open={step === Step.Plan}
             subscriptionPlans={subscriptionPlans}
           />
index 45afee9f63c605a1a13a1f7882bcef8a0c74723f..ce7d8b6f9f33cade07f8da2ea4934a349fe97be9 100644 (file)
@@ -31,7 +31,6 @@ interface Props {
   handleOrgDetailsFinish: (organization: T.Organization) => Promise<void>;
   handleOrgDetailsStepOpen: () => void;
   onDone: () => void;
-  onlyPaid?: boolean;
   organization?: T.Organization;
   step: Step;
   subscriptionPlans?: T.SubscriptionPlan[];
@@ -67,7 +66,6 @@ export default class ManualOrganizationCreate extends React.PureComponent<Props>
             createOrganization={this.handleCreateOrganization}
             onDone={this.props.onDone}
             onUpgradeFail={this.props.onUpgradeFail}
-            onlyPaid={this.props.onlyPaid}
             open={this.props.step === Step.Plan}
             subscriptionPlans={subscriptionPlans}
           />
index 0c9e8dc961a8363780753c296123912f2c2a615e..25a9964d3600654af8069cfcc42f4bf51acb665a 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { Link } from 'react-router';
-import Radio from '../../../components/controls/Radio';
+import { FreeCardPlan, PaidCardPlan } from '../components/CardPlan';
 import { translate } from '../../../helpers/l10n';
+import { AlmOrganization, AlmApplication } from '../../../app/types';
 
 export enum Plan {
   Free = 'free',
@@ -29,6 +28,8 @@ export enum Plan {
 }
 
 interface Props {
+  almApplication?: AlmApplication;
+  almOrganization?: AlmOrganization;
   onChange: (plan: Plan) => void;
   plan: Plan;
   startingPrice: string;
@@ -44,43 +45,36 @@ export default class PlanSelect extends React.PureComponent<Props> {
   };
 
   render() {
-    const { plan } = this.props;
+    const { almApplication, almOrganization, plan } = this.props;
+    const hasPrivateRepo = Boolean(almOrganization && almOrganization.privateRepos > 0);
+    const onlyPrivateRepo = Boolean(
+      hasPrivateRepo && almOrganization && almOrganization.publicRepos === 0
+    );
+
+    const cards = [
+      <PaidCardPlan
+        isRecommended={hasPrivateRepo}
+        key="paid"
+        onClick={this.handlePaidPlanClick}
+        selected={plan === Plan.Paid}
+        startingPrice={this.props.startingPrice}
+      />,
+      <FreeCardPlan
+        almName={almApplication && almApplication.name}
+        disabled={onlyPrivateRepo}
+        hasWarning={hasPrivateRepo && plan === Plan.Free}
+        key="free"
+        onClick={this.handleFreePlanClick}
+        selected={plan === Plan.Free}
+      />
+    ];
+
     return (
       <div
         aria-label={translate('onboarding.create_organization.choose_plan')}
-        className="huge-spacer-bottom"
+        className="display-flex-row huge-spacer-bottom"
         role="radiogroup">
-        <div>
-          <Radio checked={plan === Plan.Free} onCheck={this.handleFreePlanClick}>
-            <span>{translate('billing.free_plan.title')}</span>
-          </Radio>
-          <p className="note markdown little-spacer-top">
-            {translate('billing.free_plan.description')}
-          </p>
-        </div>
-        <div className="big-spacer-top">
-          <Radio checked={plan === Plan.Paid} onCheck={this.handlePaidPlanClick}>
-            <span>{translate('billing.paid_plan.title')}</span>
-          </Radio>
-          <p className="note markdown little-spacer-top">
-            <FormattedMessage
-              defaultMessage={translate('billing.paid_plan.description')}
-              id="billing.paid_plan.description"
-              values={{
-                price: this.props.startingPrice,
-                more: (
-                  <>
-                    {' '}
-                    <Link target="_blank" to="/documentation/sonarcloud-pricing/">
-                      {translate('learn_more')}
-                    </Link>
-                    <br />
-                  </>
-                )
-              }}
-            />
-          </p>
-        </div>
+        {hasPrivateRepo ? cards : cards.reverse()}
       </div>
     );
   }
index 59009a44b98bf38200eb5d465d2e0fe975e5a81e..adf99b376d2ef8b6d04d9fb5bbeda66d17b514f8 100644 (file)
@@ -21,20 +21,21 @@ import * as React from 'react';
 import BillingFormShim from './BillingFormShim';
 import PlanSelect, { Plan } from './PlanSelect';
 import { formatPrice } from './utils';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import Step from '../../tutorials/components/Step';
+import { SubmitButton } from '../../../components/ui/buttons';
 import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
-import { translate } from '../../../helpers/l10n';
 import { getExtensionStart } from '../../../app/components/extensions/utils';
-import { SubmitButton } from '../../../components/ui/buttons';
-import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { translate } from '../../../helpers/l10n';
 
 const BillingForm = withCurrentUser(BillingFormShim);
 
 interface Props {
+  almApplication?: T.AlmApplication;
+  almOrganization?: T.AlmOrganization;
   createOrganization: () => Promise<string>;
   onDone: () => void;
   onUpgradeFail?: () => void;
-  onlyPaid?: boolean;
   open: boolean;
   subscriptionPlans: T.SubscriptionPlan[];
 }
@@ -51,7 +52,7 @@ export default class PlanStep extends React.PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.state = {
-      plan: props.onlyPaid ? Plan.Paid : Plan.Free,
+      plan: props.almOrganization && props.almOrganization.privateRepos > 0 ? Plan.Paid : Plan.Free,
       ready: false,
       submitting: false
     };
@@ -100,13 +101,13 @@ export default class PlanStep extends React.PureComponent<Props, State> {
       <div className="boxed-group-inner">
         {this.state.ready && (
           <>
-            {!this.props.onlyPaid && (
-              <PlanSelect
-                onChange={this.handlePlanChange}
-                plan={this.state.plan}
-                startingPrice={formatPrice(startedPrice)}
-              />
-            )}
+            <PlanSelect
+              almApplication={this.props.almApplication}
+              almOrganization={this.props.almOrganization}
+              onChange={this.handlePlanChange}
+              plan={this.state.plan}
+              startingPrice={formatPrice(startedPrice)}
+            />
 
             {this.state.plan === Plan.Paid ? (
               <BillingForm
@@ -143,8 +144,9 @@ export default class PlanStep extends React.PureComponent<Props, State> {
   };
 
   render() {
+    const { almOrganization } = this.props;
     const stepTitle = translate(
-      this.props.onlyPaid
+      almOrganization && almOrganization.privateRepos > 0 && almOrganization.publicRepos === 0
         ? 'onboarding.create_organization.enter_payment_details'
         : 'onboarding.create_organization.choose_plan'
     );
index d4c4ed73bc8f5209a2f1d7b72c2b11a7b8efd9bc..c7999e6fa1966d261f350128c742dd63cbb10882 100644 (file)
@@ -33,6 +33,8 @@ const organization = {
   description: 'description-foo',
   key: 'key-foo',
   name: 'name-foo',
+  privateRepos: 0,
+  publicRepos: 3,
   url: 'http://example.com/foo'
 };
 
index fbb17c78fe27a2ba63630bc72961563cf11e2f4c..2fb3e40e6a77e3798a42b767e794a9dbc41b101e 100644 (file)
@@ -30,6 +30,8 @@ const almOrganization = {
   key: 'key-foo',
   name: 'name-foo',
   personal: true,
+  privateRepos: 0,
+  publicRepos: 3,
   url: 'http://example.com/foo'
 };
 
index 59cc3a8139cd5c0266ca126dc7ac673f37ce7af6..4da19584a2e479dc6736220f83c1ed23233ddb89 100644 (file)
@@ -55,6 +55,8 @@ jest.mock('../../../../api/alm-integration', () => ({
       key: 'sonarsource',
       name: 'SonarSource',
       personal: false,
+      privateRepos: 0,
+      publicRepos: 3,
       url: 'https://www.sonarsource.com'
     }
   }),
@@ -79,11 +81,22 @@ const user: T.LoggedInUser = {
   showOnboardingTutorial: false
 };
 
-const almOrganization = {
+const fooAlmOrganization = {
+  avatar: 'my-avatar',
+  key: 'foo',
+  name: 'Foo',
+  personal: true,
+  privateRepos: 0,
+  publicRepos: 3
+};
+
+const fooBarAlmOrganization = {
   avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
   key: 'Foo&Bar',
   name: 'Foo & Bar',
-  personal: true
+  personal: true,
+  privateRepos: 0,
+  publicRepos: 3
 };
 
 const boundOrganization = { key: 'foobar', name: 'Foo & Bar' };
@@ -129,12 +142,7 @@ it('should render with auto tab selected and manual disabled', async () => {
 
 it('should render with auto personal organization bind page', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization: {
-      key: 'foo',
-      name: 'Foo',
-      avatar: 'my-avatar',
-      personal: true
-    }
+    almOrganization: fooAlmOrganization
   });
   const wrapper = shallowRender({
     currentUser: { ...user, externalProvider: 'github', personalOrganization: 'foo' },
@@ -147,12 +155,7 @@ it('should render with auto personal organization bind page', async () => {
 
 it('should render with organization bind page', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization: {
-      key: 'foo',
-      name: 'Foo',
-      avatar: 'my-avatar',
-      personal: false
-    }
+    almOrganization: { ...fooAlmOrganization, personal: false }
   });
   const wrapper = shallowRender({
     currentUser: { ...user, externalProvider: 'github' },
@@ -164,7 +167,9 @@ it('should render with organization bind page', async () => {
 });
 
 it('should slugify and find a uniq organization key', async () => {
-  (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({ almOrganization });
+  (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
+    almOrganization: fooBarAlmOrganization
+  });
   (getOrganizations as jest.Mock<any>).mockResolvedValueOnce({
     organizations: [{ key: 'foo-and-bar' }, { key: 'foo-and-bar-1' }]
   });
@@ -247,9 +252,7 @@ it('should redirect to projects creation page after creation', async () => {
     state: { organization: 'foo', tab: 'manual' }
   });
 
-  wrapper.setState({
-    almOrganization: { key: 'foo', name: 'Foo', avatar: 'my-avatar', personal: false }
-  });
+  wrapper.setState({ almOrganization: { ...fooAlmOrganization, personal: false } });
   (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
   wrapper.instance().handleOrgCreated('foo');
   expect(push).toHaveBeenCalledWith({
@@ -260,7 +263,7 @@ it('should redirect to projects creation page after creation', async () => {
 
 it('should display AutoOrganizationCreate with already bound organization', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization: { ...almOrganization, personal: false },
+    almOrganization: { ...fooBarAlmOrganization, personal: false },
     boundOrganization
   });
   (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
@@ -283,7 +286,7 @@ it('should display AutoOrganizationCreate with already bound organization', asyn
 
 it('should redirect to org page when already bound and no binding in progress', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
-    almOrganization,
+    almOrganization: fooBarAlmOrganization,
     boundOrganization
   });
   const push = jest.fn();
index 3e7736019edb6dc2c07bf81ba16ea806f6d8ff56..de39d626ab541e868a42637dbf0ab436d116725f 100644 (file)
@@ -47,15 +47,6 @@ it('should render and create organization', async () => {
   expect(wrapper).toMatchSnapshot();
 });
 
-it('should preselect paid plan', async () => {
-  const wrapper = shallowRender({ onlyPaid: true });
-
-  await waitAndUpdate(wrapper);
-  wrapper.find('OrganizationDetailsForm').prop<Function>('onContinue')(organization);
-  await waitAndUpdate(wrapper);
-  expect(wrapper.find('PlanStep').prop('onlyPaid')).toBe(true);
-});
-
 function shallowRender(props: Partial<ManualOrganizationCreate['props']> = {}) {
   return shallow(
     <ManualOrganizationCreate
index ffe6c520eec14d5a8d477e978a79eec019fc2d4a..8851e7d869a11675edf81bad51a2d82a85e8534c 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import PlanSelect, { Plan } from '../PlanSelect';
+import { click } from '../../../../helpers/testUtils';
 
 it('should render and select', () => {
   const onChange = jest.fn();
-  const wrapper = shallow(<PlanSelect onChange={onChange} plan={Plan.Free} startingPrice="10" />);
+  const wrapper = shallowRender({ onChange });
   expect(wrapper).toMatchSnapshot();
 
-  wrapper.find('Radio[checked=false]').prop<Function>('onCheck')();
+  click(wrapper.find('PaidCardPlan'));
   expect(onChange).toBeCalledWith(Plan.Paid);
-
   wrapper.setProps({ plan: Plan.Paid });
   expect(wrapper).toMatchSnapshot();
 });
+
+it('should recommend paid plan', () => {
+  const wrapper = shallowRender({
+    almOrganization: { key: 'foo', name: 'Foo', personal: false, privateRepos: 1, publicRepos: 5 },
+    plan: Plan.Paid
+  });
+  expect(wrapper.find('PaidCardPlan').prop('isRecommended')).toBe(true);
+  expect(wrapper.find('FreeCardPlan').prop('disabled')).toBe(false);
+  expect(wrapper.find('FreeCardPlan').prop('hasWarning')).toBe(false);
+
+  wrapper.setProps({ plan: Plan.Free });
+  expect(wrapper.find('FreeCardPlan').prop('hasWarning')).toBe(true);
+});
+
+it('should recommend paid plan and disable free plan', () => {
+  const wrapper = shallowRender({
+    almOrganization: { key: 'foo', name: 'Foo', personal: false, privateRepos: 1, publicRepos: 0 }
+  });
+  expect(wrapper.find('PaidCardPlan').prop('isRecommended')).toBe(true);
+  expect(wrapper.find('FreeCardPlan').prop('disabled')).toBe(true);
+});
+
+function shallowRender(props: Partial<PlanSelect['props']> = {}) {
+  return shallow(
+    <PlanSelect onChange={jest.fn()} plan={Plan.Free} startingPrice="10" {...props} />
+  );
+}
index 2334b42871f1c8b422d12c5326dd5dda03653647..d5ff1e778dfa8df689eef6cac6df38999fd96d54 100644 (file)
@@ -80,15 +80,21 @@ it('should upgrade', async () => {
 it('should preselect paid plan', async () => {
   const wrapper = shallow(
     <PlanStep
+      almOrganization={{
+        avatar: 'my-avatar',
+        key: 'foo',
+        name: 'Foo',
+        personal: true,
+        privateRepos: 5,
+        publicRepos: 0
+      }}
       createOrganization={jest.fn()}
       onDone={jest.fn()}
       onUpgradeFail={jest.fn()}
-      onlyPaid={true}
       open={true}
       subscriptionPlans={subscriptionPlans}
     />
   );
   await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
   expect(wrapper.dive()).toMatchSnapshot();
 });
index 86b68fd9370fbea2e1b2041f27ccc7a190775686..d4e2edb95eda455531b0ee56a19057958415f806 100644 (file)
@@ -51,7 +51,14 @@ it('should display already bound alert message', () => {
   expect(
     shallowRender({
       almInstallId: 'foo',
-      almOrganization: { avatar: 'foo-avatar', key: 'foo', name: 'Foo', personal: false },
+      almOrganization: {
+        avatar: 'foo-avatar',
+        key: 'foo',
+        name: 'Foo',
+        personal: false,
+        privateRepos: 0,
+        publicRepos: 3
+      },
       boundOrganization: { avatar: 'bound-avatar', key: 'bound', name: 'Bound' }
     }).find('Alert')
   ).toMatchSnapshot();
index 91da2875d7020b7f3d01534dcf70de317f5da20d..f268d88ce0f7231176ce1da7c6550238bccc9802 100644 (file)
@@ -57,10 +57,30 @@ exports[`should display choice between import or creation 1`] = `
     </div>
   </OrganizationDetailsStep>
   <PlanStep
+    almApplication={
+      Object {
+        "backgroundColor": "#0052CC",
+        "iconPath": "\\"/static/authbitbucket/bitbucket.svg\\"",
+        "installationUrl": "https://bitbucket.org/install/app",
+        "key": "bitbucket",
+        "name": "BitBucket",
+      }
+    }
+    almOrganization={
+      Object {
+        "avatar": "http://example.com/avatar",
+        "description": "description-foo",
+        "key": "key-foo",
+        "name": "name-foo",
+        "personal": false,
+        "privateRepos": 0,
+        "publicRepos": 3,
+        "url": "http://example.com/foo",
+      }
+    }
     createOrganization={[Function]}
     onDone={[MockFunction]}
     onUpgradeFail={[MockFunction]}
-    onlyPaid={false}
     open={false}
     subscriptionPlans={
       Array [
@@ -124,6 +144,8 @@ exports[`should render prefilled and create org 1`] = `
           "key": "key-foo",
           "name": "name-foo",
           "personal": false,
+          "privateRepos": 0,
+          "publicRepos": 3,
           "url": "http://example.com/foo",
         }
       }
@@ -131,10 +153,30 @@ exports[`should render prefilled and create org 1`] = `
     />
   </OrganizationDetailsStep>
   <PlanStep
+    almApplication={
+      Object {
+        "backgroundColor": "#0052CC",
+        "iconPath": "\\"/static/authbitbucket/bitbucket.svg\\"",
+        "installationUrl": "https://bitbucket.org/install/app",
+        "key": "bitbucket",
+        "name": "BitBucket",
+      }
+    }
+    almOrganization={
+      Object {
+        "avatar": "http://example.com/avatar",
+        "description": "description-foo",
+        "key": "key-foo",
+        "name": "name-foo",
+        "personal": false,
+        "privateRepos": 0,
+        "publicRepos": 3,
+        "url": "http://example.com/foo",
+      }
+    }
     createOrganization={[Function]}
     onDone={[MockFunction]}
     onUpgradeFail={[MockFunction]}
-    onlyPaid={false}
     open={false}
     subscriptionPlans={
       Array [
index cece5c58e9aa9a51795fb4e5215139c3ddb4395c..c19b4cff019b1048481ab52fabf96132089e2ae5 100644 (file)
@@ -58,9 +58,29 @@ exports[`should render correctly 1`] = `
     />
   </OrganizationDetailsStep>
   <PlanStep
+    almApplication={
+      Object {
+        "backgroundColor": "#0052CC",
+        "iconPath": "\\"/static/authbitbucket/bitbucket.svg\\"",
+        "installationUrl": "https://bitbucket.org/install/app",
+        "key": "bitbucket",
+        "name": "BitBucket",
+      }
+    }
+    almOrganization={
+      Object {
+        "avatar": "http://example.com/avatar",
+        "description": "description-foo",
+        "key": "key-foo",
+        "name": "name-foo",
+        "personal": true,
+        "privateRepos": 0,
+        "publicRepos": 3,
+        "url": "http://example.com/foo",
+      }
+    }
     createOrganization={[Function]}
     onDone={[MockFunction]}
-    onlyPaid={false}
     open={false}
     subscriptionPlans={
       Array [
index e69bd5536b6d36958d92beaf06f42d1ebea24cb7..f2bb69deb5d2160adec6dda0a211db964deda076 100644 (file)
@@ -43,6 +43,8 @@ exports[`should render with auto personal organization bind page 2`] = `
           "key": "foo",
           "name": "Foo",
           "personal": true,
+          "privateRepos": 0,
+          "publicRepos": 3,
         }
       }
       handleCancelImport={[Function]}
@@ -276,6 +278,8 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
           "key": "sonarsource",
           "name": "SonarSource",
           "personal": false,
+          "privateRepos": 0,
+          "publicRepos": 3,
           "url": "https://www.sonarsource.com",
         }
       }
@@ -484,6 +488,8 @@ exports[`should render with organization bind page 2`] = `
           "key": "foo",
           "name": "Foo",
           "personal": false,
+          "privateRepos": 0,
+          "publicRepos": 3,
         }
       }
       className=""
index 364691fe3aad8801a1496a6df25e8e065d803108..25680a523c485fe6e6b519981de1cdfa73e1bb30 100644 (file)
 exports[`should render and select 1`] = `
 <div
   aria-label="onboarding.create_organization.choose_plan"
-  className="huge-spacer-bottom"
+  className="display-flex-row huge-spacer-bottom"
   role="radiogroup"
 >
-  <div>
-    <Radio
-      checked={true}
-      onCheck={[Function]}
-    >
-      <span>
-        billing.free_plan.title
-      </span>
-    </Radio>
-    <p
-      className="note markdown little-spacer-top"
-    >
-      billing.free_plan.description
-    </p>
-  </div>
-  <div
-    className="big-spacer-top"
-  >
-    <Radio
-      checked={false}
-      onCheck={[Function]}
-    >
-      <span>
-        billing.paid_plan.title
-      </span>
-    </Radio>
-    <p
-      className="note markdown little-spacer-top"
-    >
-      <FormattedMessage
-        defaultMessage="billing.paid_plan.description"
-        id="billing.paid_plan.description"
-        values={
-          Object {
-            "more": <React.Fragment>
-               
-              <Link
-                onlyActiveOnIndex={false}
-                style={Object {}}
-                target="_blank"
-                to="/documentation/sonarcloud-pricing/"
-              >
-                learn_more
-              </Link>
-              <br />
-            </React.Fragment>,
-            "price": "10",
-          }
-        }
-      />
-    </p>
-  </div>
+  <FreeCardPlan
+    disabled={false}
+    hasWarning={false}
+    key="free"
+    onClick={[Function]}
+    selected={true}
+  />
+  <PaidCardPlan
+    isRecommended={false}
+    key="paid"
+    onClick={[Function]}
+    selected={false}
+    startingPrice="10"
+  />
 </div>
 `;
 
 exports[`should render and select 2`] = `
 <div
   aria-label="onboarding.create_organization.choose_plan"
-  className="huge-spacer-bottom"
+  className="display-flex-row huge-spacer-bottom"
   role="radiogroup"
 >
-  <div>
-    <Radio
-      checked={false}
-      onCheck={[Function]}
-    >
-      <span>
-        billing.free_plan.title
-      </span>
-    </Radio>
-    <p
-      className="note markdown little-spacer-top"
-    >
-      billing.free_plan.description
-    </p>
-  </div>
-  <div
-    className="big-spacer-top"
-  >
-    <Radio
-      checked={true}
-      onCheck={[Function]}
-    >
-      <span>
-        billing.paid_plan.title
-      </span>
-    </Radio>
-    <p
-      className="note markdown little-spacer-top"
-    >
-      <FormattedMessage
-        defaultMessage="billing.paid_plan.description"
-        id="billing.paid_plan.description"
-        values={
-          Object {
-            "more": <React.Fragment>
-               
-              <Link
-                onlyActiveOnIndex={false}
-                style={Object {}}
-                target="_blank"
-                to="/documentation/sonarcloud-pricing/"
-              >
-                learn_more
-              </Link>
-              <br />
-            </React.Fragment>,
-            "price": "10",
-          }
-        }
-      />
-    </p>
-  </div>
+  <FreeCardPlan
+    disabled={false}
+    hasWarning={false}
+    key="free"
+    onClick={[Function]}
+    selected={false}
+  />
+  <PaidCardPlan
+    isRecommended={false}
+    key="paid"
+    onClick={[Function]}
+    selected={true}
+    startingPrice="10"
+  />
 </div>
 `;
index b03f3c1ef55ac1b6d9647059d6f0f6e248eeb4b5..a979c50685f1b76e4d8851b18c3a750a53951219 100644 (file)
@@ -1,18 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should preselect paid plan 1`] = `
-<Step
-  finished={false}
-  onOpen={[Function]}
-  open={true}
-  renderForm={[Function]}
-  renderResult={[Function]}
-  stepNumber={2}
-  stepTitle="onboarding.create_organization.enter_payment_details"
-/>
-`;
-
-exports[`should preselect paid plan 2`] = `
 <div
   className="boxed-group onboarding-step is-open"
 >
@@ -34,6 +22,21 @@ exports[`should preselect paid plan 2`] = `
     <div
       className="boxed-group-inner"
     >
+      <PlanSelect
+        almOrganization={
+          Object {
+            "avatar": "my-avatar",
+            "key": "foo",
+            "name": "Foo",
+            "personal": true,
+            "privateRepos": 5,
+            "publicRepos": 0,
+          }
+        }
+        onChange={[Function]}
+        plan="paid"
+        startingPrice="billing.price_format.100"
+      />
       <Connect(withCurrentUser(BillingFormShim))
         onCommit={[MockFunction]}
         onFailToUpgrade={[MockFunction]}
diff --git a/server/sonar-web/src/main/js/components/icons-components/RecommendedIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/RecommendedIcon.tsx
new file mode 100644 (file)
index 0000000..85dcebe
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * 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 Icon, { IconProps } from './Icon';
+
+export default function RecommendedIcon({ className, fill = 'currentColor', size }: IconProps) {
+  return (
+    <Icon className={className} size={size}>
+      <path
+        d="M15.089 13.199l-1.742-3.736c-0.962 1.401-2.464 2.398-4.203 2.701l1.459 3.128c0.186 0.4 0.764 0.373 0.914-0.040l0.748-2.054 0.154-0.072 2.054 0.748c0.412 0.151 0.804-0.276 0.618-0.675zM8.040 0.384c-3.003 0-5.446 2.443-5.446 5.446s2.443 5.446 5.446 5.446c3.003 0 5.446-2.443 5.446-5.446s-2.443-5.446-5.446-5.446zM10.689 5.429l-0.966 0.941 0.228 1.33c0.070 0.406-0.358 0.711-0.718 0.522l-1.194-0.628-1.194 0.628c-0.363 0.19-0.788-0.118-0.718-0.522l0.228-1.33-0.966-0.941c-0.293-0.286-0.131-0.786 0.274-0.844l1.335-0.194 0.597-1.209c0.181-0.367 0.707-0.368 0.888 0l0.597 1.209 1.335 0.194c0.405 0.059 0.568 0.558 0.274 0.844zM2.732 9.463l-1.742 3.736c-0.187 0.4 0.208 0.825 0.618 0.674l2.054-0.748 0.154 0.072 0.748 2.054c0.15 0.412 0.727 0.441 0.914 0.040l1.459-3.128c-1.739-0.302-3.241-1.3-4.203-2.701z"
+        style={{ fill }}
+      />
+    </Icon>
+  );
+}
index cfaba65943151ecfbc034aad497a2f149be12397..d73e88fcbeb20e798633646171738d2d5ce691a9 100644 (file)
@@ -36,8 +36,12 @@ it('#isVSTS', () => {
 });
 
 it('#isPersonal', () => {
-  expect(isPersonal({ key: 'foo', name: 'Foo', personal: true })).toBeTruthy();
-  expect(isPersonal({ key: 'foo', name: 'Foo', personal: false })).toBeFalsy();
+  expect(
+    isPersonal({ key: 'foo', name: 'Foo', personal: true, privateRepos: 0, publicRepos: 3 })
+  ).toBeTruthy();
+  expect(
+    isPersonal({ key: 'foo', name: 'Foo', personal: false, privateRepos: 0, publicRepos: 3 })
+  ).toBeFalsy();
 });
 
 it('#sanitizeAlmId', () => {
index 2b4196f9e2a0bc0cf55f727e9a10017417146cbc..ea32be1e07bbd2f5836efcd9582dfc3e6e367de5 100644 (file)
@@ -132,6 +132,7 @@ quality_profile=Quality Profile
 raw=Raw
 recent_history=Recent History
 recently_browsed=Recently Browsed
+recommended=Recommended
 refresh=Refresh
 reload=Reload
 remove=Remove