]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-379 Enable users sync on existing ALM bound organizations
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 5 Feb 2019 08:38:19 +0000 (09:38 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 6 Mar 2019 10:30:41 +0000 (11:30 +0100)
* Display org sync advertisement block
* Add membersSync prop to organization type and update mock functions
* Extract RadioCard from CardPlan
* Allow to customize Modal through ConfirmButton
* Add user sync configuration modal
* Show help tooltip when user sync is activated

71 files changed:
server/sonar-server/src/main/java/org/sonar/server/user/ws/SetSettingAction.java
server/sonar-server/src/test/java/org/sonar/server/user/ws/SetSettingActionTest.java
server/sonar-web/src/main/js/api/organizations.ts
server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx
server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap
server/sonar-web/src/main/js/app/components/notifications/notifications.css
server/sonar-web/src/main/js/app/styles/components/badges.css
server/sonar-web/src/main/js/app/styles/components/modals.css
server/sonar-web/src/main/js/app/theme.js
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/apps/create/components/CardPlan.css [deleted file]
server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx
server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationSelect-test.tsx
server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/AlmApplicationInstalling.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageSonarCloud-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationInput-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageSonarCloud-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationInput-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap
server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx
server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx
server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx
server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx
server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap
server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx
server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx
server/sonar-web/src/main/js/components/controls/Modal.tsx
server/sonar-web/src/main/js/components/controls/RadioCard.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/RadioCard.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/SimpleModal.tsx
server/sonar-web/src/main/js/components/controls/__tests__/RadioCard-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/hoc/utils.ts
server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx
server/sonar-web/src/main/js/components/ui/NewInfoBox.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts
server/sonar-web/src/main/js/helpers/almIntegrations.ts
server/sonar-web/src/main/js/helpers/testMocks.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 775b61b0aa6a964cf0222612e609c78e098f36ab..116b2afca93d246e9e6c6845d3bfc5c4d9e2d43b 100644 (file)
@@ -57,7 +57,7 @@ public class SetSettingAction implements UsersWsAction {
       .setRequired(true)
       .setMaximumLength(100)
       .setDescription("Setting key")
-      .setPossibleValues("notifications.optOut", UserUpdater.NOTIFICATIONS_READ_DATE);
+      .setPossibleValues("notifications.optOut", UserUpdater.NOTIFICATIONS_READ_DATE, "organizations.members.dismissSyncNotif");
 
     action.createParam(PARAM_VALUE)
       .setRequired(true)
index ba8d78ea53588270454a44a0e2299120be690e9d..f2175a39a03f0a8402e3fab81cb7be000f717f86 100644 (file)
@@ -91,11 +91,17 @@ public class SetSettingActionTest {
       .setParam("value", "true")
       .execute();
 
+    ws.newRequest()
+      .setParam("key", "organizations.members.dismissSyncNotif")
+      .setParam("value", "org1,org2")
+      .execute();
+
     assertThat(db.getDbClient().userPropertiesDao().selectByUser(db.getSession(), user))
       .extracting(UserPropertyDto::getKey, UserPropertyDto::getValue)
       .containsExactlyInAnyOrder(
         tuple("notifications.readDate", "1234"),
-        tuple("notifications.optOut", "true"));
+        tuple("notifications.optOut", "true"),
+        tuple("organizations.members.dismissSyncNotif", "org1,org2"));
   }
 
   @Test
@@ -123,7 +129,10 @@ public class SetSettingActionTest {
         tuple("key", true, 100),
         tuple("value", true, 4000));
 
-    assertThat(definition.param("key").possibleValues()).containsExactlyInAnyOrder("notifications.optOut", "notifications.readDate");
+    assertThat(definition.param("key").possibleValues()).containsExactlyInAnyOrder(
+      "notifications.optOut",
+      "notifications.readDate",
+      "organizations.members.dismissSyncNotif");
   }
 
 }
index 736dc934d39f870fd3330b991d434b589775d810..cc2d8c0f20576626788e729d434aa5e382be9bb9 100644 (file)
@@ -39,6 +39,7 @@ export function getOrganization(key: string): Promise<T.Organization | undefined
 
 interface GetOrganizationNavigation {
   adminPages: T.Extension[];
+  alm?: { key: string; membersSync: boolean; url: string };
   canUpdateProjectsVisibilityToPrivate: boolean;
   isDefault: boolean;
   pages: T.Extension[];
@@ -102,3 +103,11 @@ export interface OrganizationBilling {
 export function getOrganizationBilling(organization: string): Promise<OrganizationBilling> {
   return getJSON('/api/billing/show', { organization, p: 1, ps: 1 });
 }
+
+export function setOrganizationMemberSync(data: { enabled: boolean; organization: string }) {
+  return post('/api/organizations/set_members_sync', data).catch(throwGlobalError);
+}
+
+export function syncMembers(organization: string) {
+  return post('/api/organizations/sync_members', { organization }).catch(throwGlobalError);
+}
index 2c1c50da27da8615b917190e54fbc837aebe61e2..957a0f78ce98367d1ab52d258f11a7ee5a917bcb 100644 (file)
@@ -71,7 +71,7 @@ export default class NavLatestNotification extends React.PureComponent<Props> {
           <>
             <li className="navbar-latest-notification" onClick={this.props.onClick}>
               <div className="navbar-latest-notification-wrapper">
-                <span className="badge">{translate('new')}</span>
+                <span className="badge badge-new">{translate('new')}</span>
                 <span className="label">{lastNews.notification}</span>
               </div>
             </li>
index 5e2bc92085818a1c4abc0f309dc79ebaf34eab64..83cb55464a0384d3a35c62bd358a2b006fc31144 100644 (file)
@@ -10,7 +10,7 @@ exports[`should render correctly if there are new features, and the user has not
       className="navbar-latest-notification-wrapper"
     >
       <span
-        className="badge"
+        className="badge badge-new"
       >
         new
       </span>
index 0f2209127e4a22b95e8143b67e1fe01e40e968e4..e1760937ab2a734aff4c25fabc043516246f432a 100644 (file)
   color: var(--sonarcloudBlack300);
 }
 
-.navbar-latest-notification-wrapper .badge {
+.navbar-latest-notification-wrapper .badge-new {
   position: absolute;
-  height: 18px;
   margin-right: var(--gridSize);
   left: calc(var(--gridSize) / 2);
   top: 5px;
-  font-size: var(--verySmallFontSize);
-  text-transform: uppercase;
-  background-color: var(--lightBlue);
-  color: var(--darkBlue);
 }
 
 .navbar-latest-notification-wrapper .label {
index 54a16858026fa6094cc6722f9bab3a985055ac8f..cdd025107ece696d3a4172d2d3308c859b3a2fa2 100644 (file)
@@ -80,6 +80,14 @@ a.badge {
   line-height: calc(var(--tinyControlHeight) - 1px) !important;
 }
 
+.badge-new {
+  height: 18px;
+  font-size: var(--verySmallFontSize);
+  text-transform: uppercase;
+  background-color: var(--lightBlue);
+  color: var(--darkBlue);
+}
+
 .badge-muted {
   background-color: transparent;
   color: var(--secondFontColor);
index e1f7c4e100df0df75afd0872dd982f1a429af3df..7a1b8a4e72b109b321f7d1fea12c8ff8edddb87e 100644 (file)
@@ -42,8 +42,8 @@
 }
 
 .modal-medium {
-  width: 800px;
-  margin-left: -400px;
+  width: 830px;
+  margin-left: -415px;
 }
 
 .modal-large {
index 322b8c114618abc64446301968ca131cc514975b..25ce5c25956860233b499662506b7234d6475a63 100644 (file)
@@ -24,6 +24,7 @@ const grid = 8;
 module.exports = {
   // colors
   blue: '#4b9fd5',
+  veryLightBlue: '#f2faff',
   lightBlue: '#cae3f2',
   darkBlue: '#236a97',
   green: '#00aa00',
index e51039b9405f59b080beee7e2dcc5aebdabdd336..9f70f1e0f979dbf62692094f0b63b071ce030b18 100644 (file)
@@ -215,7 +215,10 @@ declare namespace T {
     value: string;
   }
 
-  type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate';
+  type CurrentUserSettingNames =
+    | 'notifications.optOut'
+    | 'notifications.readDate'
+    | 'organizations.members.dismissSyncNotif';
 
   export interface CustomMeasure {
     createdAt?: string;
@@ -499,7 +502,7 @@ declare namespace T {
 
   export interface Organization extends OrganizationBase {
     actions?: OrganizationActions;
-    alm?: { key: string; url: string };
+    alm?: OrganizationAlm;
     adminPages?: Extension[];
     canUpdateProjectsVisibilityToPrivate?: boolean;
     guarded?: boolean;
@@ -510,6 +513,12 @@ declare namespace T {
     subscription?: OrganizationSubscription;
   }
 
+  export interface OrganizationAlm {
+    key: string;
+    membersSync: boolean;
+    url: string;
+  }
+
   export interface OrganizationBase {
     avatar?: string;
     description?: string;
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
deleted file mode 100644 (file)
index 174e3c3..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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));
-  transition: all 0.2s ease;
-}
-
-.card-plan.animated {
-  height: 0;
-  border-width: 0;
-  overflow: hidden;
-}
-
-.card-plan.animated.open {
-  height: 210px;
-  border-width: 1px;
-}
-
-.card-plan.highlight {
-  box-shadow: var(--defaultShadow);
-}
-
-.card-plan:last-child {
-  margin-right: 0;
-}
-
-.card-plan:focus {
-  outline: none;
-}
-
-.card-plan-actionable {
-  cursor: pointer;
-}
-
-.card-plan-actionable:not(.disabled):hover {
-  box-shadow: var(--defaultShadow);
-  transform: translateY(-2px);
-}
-
-.card-plan-actionable.selected {
-  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);
-  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 .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(--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
deleted file mode 100644 (file)
index 6ffb21d..0000000
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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 UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages';
-import RecommendedIcon from '../../../components/icons-components/RecommendedIcon';
-import { Alert } from '../../../components/ui/Alert';
-import { formatPrice } from '../organization/utils';
-import { translate } from '../../../helpers/l10n';
-import './CardPlan.css';
-
-interface Props {
-  className?: string;
-  disabled?: boolean;
-  onClick?: () => void;
-  selected?: boolean;
-  startingPrice?: number;
-}
-
-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 !== 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 && (
-        <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 startingPrice={0} title={translate('billing.free_plan.title')} {...props}>
-      <>
-        <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
-              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) {
-  return (
-    <CardPlan
-      recommended={isRecommended ? translate('billing.paid_plan.recommended') : undefined}
-      title={translate('billing.paid_plan.title')}
-      {...props}>
-      <>
-        <UpgradeOrganizationAdvantages />
-        <div className="big-spacer-left">
-          <Link className="spacer-left" target="_blank" to="/about/pricing">
-            {translate('billing.pricing.learn_more')}
-          </Link>
-        </div>
-      </>
-    </CardPlan>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx
new file mode 100644 (file)
index 0000000..494220f
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 RadioCard, { RadioCardProps } from '../../../components/controls/RadioCard';
+import { Alert } from '../../../components/ui/Alert';
+import { formatPrice } from '../organization/utils';
+import { translate } from '../../../helpers/l10n';
+
+interface Props extends RadioCardProps {
+  almName?: string;
+  hasWarning: boolean;
+}
+
+export default function FreeCardPlan({ almName, hasWarning, ...props }: Props) {
+  const showInfo = almName && props.disabled;
+  const showWarning = almName && hasWarning && !props.disabled;
+
+  return (
+    <RadioCard title={translate('billing.free_plan.title')} titleInfo={formatPrice(0)} {...props}>
+      <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
+            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>
+      )}
+    </RadioCard>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx
new file mode 100644 (file)
index 0000000..2cc3d59
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages';
+import RadioCard, { RadioCardProps } from '../../../components/controls/RadioCard';
+import { formatPrice } from '../organization/utils';
+import { translate } from '../../../helpers/l10n';
+
+interface Props extends RadioCardProps {
+  isRecommended: boolean;
+  startingPrice?: number;
+}
+
+export default function PaidCardPlan({ isRecommended, startingPrice, ...props }: Props) {
+  return (
+    <RadioCard
+      recommended={isRecommended ? translate('billing.paid_plan.recommended') : undefined}
+      title={translate('billing.paid_plan.title')}
+      titleInfo={
+        startingPrice !== undefined && (
+          <FormattedMessage
+            defaultMessage={translate('billing.price_from_x')}
+            id="billing.price_from_x"
+            values={{
+              price: <span className="big">{formatPrice(startingPrice)}</span>
+            }}
+          />
+        )
+      }
+      {...props}>
+      <UpgradeOrganizationAdvantages />
+      <div className="big-spacer-left">
+        <Link className="spacer-left" target="_blank" to="/about/pricing">
+          {translate('billing.pricing.learn_more')}
+        </Link>
+      </div>
+    </RadioCard>
+  );
+}
index b139c12ee6a50aff0ac5a115f03c207739e58a10..bf54a7342880a56c7adba99748870cf03805b254 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 UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages';
 import UpgradeOrganizationModal from './UpgradeOrganizationModal';
-import CardPlan from './CardPlan';
+import RadioCard from '../../../components/controls/RadioCard';
 import { Button } from '../../../components/ui/buttons';
-import { translate, hasMessage } from '../../../helpers/l10n';
+import { formatPrice } from '../organization/utils';
 import { getSubscriptionPlans } from '../../../api/billing';
+import { translate, hasMessage } from '../../../helpers/l10n';
 
 interface Props {
   className?: string;
@@ -84,10 +86,20 @@ export default class UpgradeOrganizationBox extends React.PureComponent<Props, S
 
     return (
       <>
-        <CardPlan
+        <RadioCard
           className={this.props.className}
-          startingPrice={startingPrice}
-          title={translate('billing.upgrade_box.header')}>
+          title={translate('billing.upgrade_box.header')}
+          titleInfo={
+            startingPrice !== undefined && (
+              <FormattedMessage
+                defaultMessage={translate('billing.price_from_x')}
+                id="billing.price_from_x"
+                values={{
+                  price: <span className="big">{formatPrice(startingPrice)}</span>
+                }}
+              />
+            )
+          }>
           <>
             <UpgradeOrganizationAdvantages />
             <div className="big-spacer-left">
@@ -99,7 +111,7 @@ export default class UpgradeOrganizationBox extends React.PureComponent<Props, S
               </Link>
             </div>
           </>
-        </CardPlan>
+        </RadioCard>
         {upgradeOrganizationModal && (
           <UpgradeOrganizationModal
             insideModal={this.props.insideModal}
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
deleted file mode 100644 (file)
index f44e5f5..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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, startingPrice: 0 });
-  expect(wrapper).toMatchSnapshot();
-});
-
-describe('#FreeCardPlan', () => {
-  it('should render', () => {
-    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', () => {
-    expect(shallow(<PaidCardPlan isRecommended={true} startingPrice={10} />)).toMatchSnapshot();
-  });
-});
diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx
new file mode 100644 (file)
index 0000000..3c4cfec
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 FreeCardPlan from '../FreeCardPlan';
+
+it('should render', () => {
+  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();
+});
index fb5d1e46245eb05687608b75615f60754fca936c..6d607b0117dbab830d98e71508615f920090c2cf 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import OrganizationSelect, { getOptionRenderer } from '../OrganizationSelect';
+import { mockOrganization, mockOrganizationWithAlm } from '../../../../helpers/testMocks';
 
-const organizations = [
-  { key: 'foo', name: 'Foo' },
-  { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' }
-];
+const organizations = [mockOrganization(), mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })];
 
 it('should render correctly', () => {
   expect(
diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx
new file mode 100644 (file)
index 0000000..b775c18
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 PaidCardPlan from '../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
deleted file mode 100644 (file)
index f98ce7f..0000000
+++ /dev/null
@@ -1,267 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`#FreeCardPlan should render 1`] = `
-<CardPlan
-  startingPrice={0}
-  title="billing.free_plan.title"
->
-  <div
-    className="spacer-left"
-  >
-    <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"
->
-  <div
-    className="spacer-left"
-  >
-    <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"
-  >
-    <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}
-  startingPrice={0}
-  title="billing.free_plan.title"
->
-  <div
-    className="spacer-left"
-  >
-    <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"
-  >
-    <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 1`] = `
-<CardPlan
-  recommended="billing.paid_plan.recommended"
-  startingPrice={10}
-  title="billing.paid_plan.title"
->
-  <UpgradeOrganizationAdvantages />
-  <div
-    className="big-spacer-left"
-  >
-    <Link
-      className="spacer-left"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      target="_blank"
-      to="/about/pricing"
-    >
-      billing.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>
-  </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"
-          >
-            billing.price_format.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>
-`;
diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap
new file mode 100644 (file)
index 0000000..096ad74
--- /dev/null
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<RadioCard
+  title="billing.free_plan.title"
+  titleInfo="billing.price_format.0"
+>
+  <div
+    className="spacer-left"
+  >
+    <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>
+</RadioCard>
+`;
+
+exports[`should render disabled with info 1`] = `
+<RadioCard
+  disabled={true}
+  title="billing.free_plan.title"
+  titleInfo="billing.price_format.0"
+>
+  <div
+    className="spacer-left"
+  >
+    <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"
+  >
+    <FormattedMessage
+      defaultMessage="billing.free_plan.not_available_info"
+      id="billing.free_plan.not_available_info"
+      values={
+        Object {
+          "alm": "GitHub",
+        }
+      }
+    />
+  </Alert>
+</RadioCard>
+`;
+
+exports[`should render with warning 1`] = `
+<RadioCard
+  selected={true}
+  title="billing.free_plan.title"
+  titleInfo="billing.price_format.0"
+>
+  <div
+    className="spacer-left"
+  >
+    <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"
+  >
+    <FormattedMessage
+      defaultMessage="billing.free_plan.private_repo_warning"
+      id="billing.free_plan.private_repo_warning"
+      values={
+        Object {
+          "alm": "GitHub",
+        }
+      }
+    />
+  </Alert>
+</RadioCard>
+`;
index 1003db9058728455e5978d5046a0218c36d685f8..8e373bfac39cf6c09bb31c38b4cd5e8f3fb3a5db 100644 (file)
@@ -14,7 +14,8 @@ exports[`should render correctly 1`] = `
       Object {
         "alm": Object {
           "key": "github",
-          "url": "",
+          "membersSync": false,
+          "url": "https://github.com/foo",
         },
         "key": "bar",
         "name": "Bar",
diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap
new file mode 100644 (file)
index 0000000..94ac5a8
--- /dev/null
@@ -0,0 +1,38 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<RadioCard
+  recommended="billing.paid_plan.recommended"
+  title="billing.paid_plan.title"
+  titleInfo={
+    <FormattedMessage
+      defaultMessage="billing.price_from_x"
+      id="billing.price_from_x"
+      values={
+        Object {
+          "price": <span
+            className="big"
+          >
+            billing.price_format.10
+          </span>,
+        }
+      }
+    />
+  }
+>
+  <UpgradeOrganizationAdvantages />
+  <div
+    className="big-spacer-left"
+  >
+    <Link
+      className="spacer-left"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      target="_blank"
+      to="/about/pricing"
+    >
+      billing.pricing.learn_more
+    </Link>
+  </div>
+</RadioCard>
+`;
index 249f04ebdce8ca75cf89312b82596c5325448780..e389049612a340a2ff750cbf71c7c4601d330a87 100644 (file)
@@ -2,9 +2,23 @@
 
 exports[`should render correctly 1`] = `
 <Fragment>
-  <CardPlan
-    startingPrice={10}
+  <RadioCard
     title="billing.upgrade_box.header"
+    titleInfo={
+      <FormattedMessage
+        defaultMessage="billing.price_from_x"
+        id="billing.price_from_x"
+        values={
+          Object {
+            "price": <span
+              className="big"
+            >
+              billing.price_format.10
+            </span>,
+          }
+        }
+      />
+    }
   >
     <UpgradeOrganizationAdvantages />
     <div
@@ -26,6 +40,6 @@ exports[`should render correctly 1`] = `
         billing.pricing.learn_more
       </Link>
     </div>
-  </CardPlan>
+  </RadioCard>
 </Fragment>
 `;
index 29c1a79e333b56de8c840856dfba5834ed6b5c00..007b7b23d2b4c191780608e16767824cb4833020 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { sanitizeAlmId } from '../../../helpers/almIntegrations';
 
 export default function AlmApplicationInstalling({ almKey }: { almKey?: string }) {
   return (
@@ -29,9 +30,10 @@ export default function AlmApplicationInstalling({ almKey }: { almKey?: string }
           <div className="huge-spacer-top text-center">
             <i className="spinner" />
             <p className="big-spacer-top">
-              {almKey
-                ? translate('onboarding.import_organization.installing', almKey)
-                : translate('onboarding.import_organization.installing')}
+              {translate(
+                'onboarding.import_organization.installing',
+                sanitizeAlmId(almKey) || 'ALM'
+              )}
             </p>
           </div>
         </div>
index a14261cacded49a5446f7e1a7bc7d09f4bc304ec..da1dc977e4f339fd36549454aa5d5fe4986327e1 100644 (file)
@@ -18,7 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { FreeCardPlan, PaidCardPlan } from '../components/CardPlan';
+import FreeCardPlan from '../components/FreeCardPlan';
+import PaidCardPlan from '../components/PaidCardPlan';
 import { translate } from '../../../helpers/l10n';
 
 export enum Plan {
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx
new file mode 100644 (file)
index 0000000..6067360
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 AlmApplicationInstalling from '../AlmApplicationInstalling';
+
+it('should render correctly', () => {
+  expect(shallow(<AlmApplicationInstalling />)).toMatchSnapshot();
+  expect(shallow(<AlmApplicationInstalling almKey="github" />)).toMatchSnapshot();
+});
index 1537668a8fb31f082b81f9be64e31e145a3cb13e..9330ea3039cc55fcbd2f1d667a3e87292ae66c57 100644 (file)
@@ -30,7 +30,11 @@ import {
 import { getSubscriptionPlans } from '../../../../api/billing';
 import { getOrganizations } from '../../../../api/organizations';
 import { get, remove } from '../../../../helpers/storage';
-import { mockRouter } from '../../../../helpers/testMocks';
+import {
+  mockRouter,
+  mockOrganizationWithAdminActions,
+  mockOrganizationWithAlm
+} from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
 
 jest.mock('../../../../api/billing', () => ({
@@ -320,9 +324,9 @@ function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
       skipOnboarding={jest.fn()}
       updateOrganization={jest.fn()}
       userOrganizations={[
-        { actions: { admin: true }, key: 'foo', name: 'Foo' },
-        { actions: { admin: true }, alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' },
-        { actions: { admin: false }, key: 'baz', name: 'Baz' }
+        mockOrganizationWithAdminActions(),
+        mockOrganizationWithAdminActions(mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })),
+        mockOrganizationWithAdminActions({ key: 'baz', name: 'Baz' }, { admin: false })
       ]}
       {...props}
     />
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap
new file mode 100644 (file)
index 0000000..a66e47e
--- /dev/null
@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<DeferredSpinner
+  customSpinner={
+    <div
+      className="sonarcloud page page-limited"
+    >
+      <div
+        className="huge-spacer-top text-center"
+      >
+        <i
+          className="spinner"
+        />
+        <p
+          className="big-spacer-top"
+        >
+          onboarding.import_organization.installing.ALM
+        </p>
+      </div>
+    </div>
+  }
+  timeout={100}
+/>
+`;
+
+exports[`should render correctly 2`] = `
+<DeferredSpinner
+  customSpinner={
+    <div
+      className="sonarcloud page page-limited"
+    >
+      <div
+        className="huge-spacer-top text-center"
+      >
+        <i
+          className="spinner"
+        />
+        <p
+          className="big-spacer-top"
+        >
+          onboarding.import_organization.installing.github
+        </p>
+      </div>
+    </div>
+  }
+  timeout={100}
+/>
+`;
index 37b3a4531189b9f05e30b7abc4f77860011b502d..ca1c9b9201c5a5368f2ae93ee52c6622e6129aa8 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import AutoProjectCreate from '../AutoProjectCreate';
+import {
+  mockOrganizationWithAdminActions,
+  mockOrganizationWithAlm
+} from '../../../../helpers/testMocks';
 
 const almApplication = {
   backgroundColor: 'blue',
@@ -30,19 +34,8 @@ const almApplication = {
 };
 
 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'
-  }
+  mockOrganizationWithAdminActions(mockOrganizationWithAlm({ subscription: 'FREE' })),
+  mockOrganizationWithAlm({ key: 'bar', name: 'Bar', subscription: 'FREE' })
 ];
 
 it('should display the provider app install button', () => {
index 9e1a6e66803337a974165618ac2e5ef9acd7344f..5d3f92ab7d8c741ce0f216b43dd978c4e01e9468 100644 (file)
@@ -21,7 +21,11 @@ import * as React from 'react';
 import { shallow } from 'enzyme';
 import { CreateProjectPageSonarCloud } from '../CreateProjectPageSonarCloud';
 import { getAlmAppInfo } from '../../../../api/alm-integration';
-import { mockRouter } from '../../../../helpers/testMocks';
+import {
+  mockRouter,
+  mockOrganizationWithAdminActions,
+  mockOrganizationWithAlm
+} from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
 
 jest.mock('../../../../api/alm-integration', () => ({
@@ -86,9 +90,15 @@ function getWrapper(props = {}) {
       router={mockRouter()}
       skipOnboarding={jest.fn()}
       userOrganizations={[
-        { actions: { provision: true }, key: 'foo', name: 'Foo' },
-        { actions: { provision: true }, alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' },
-        { actions: { provision: false }, key: 'baz', name: 'Baz' }
+        mockOrganizationWithAdminActions({}, { admin: false, provision: true }),
+        mockOrganizationWithAdminActions(mockOrganizationWithAlm({ key: 'bar', name: 'Bar' }), {
+          admin: false,
+          provision: true
+        }),
+        mockOrganizationWithAdminActions(
+          { key: 'baz', name: 'Baz' },
+          { admin: false, provision: false }
+        )
       ]}
       {...props}
     />
index 44a1183baed307c83e98bd7e913215e3eb6b7e7e..35ff3d1878e6a5868565a4a8189a095a45c21d01 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import { OrganizationInput } from '../OrganizationInput';
-import { mockRouter } from '../../../../helpers/testMocks';
+import {
+  mockRouter,
+  mockOrganization,
+  mockOrganizationWithAlm
+} from '../../../../helpers/testMocks';
 
-const organizations = [
-  { key: 'foo', name: 'Foo' },
-  { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' }
-];
+const organizations = [mockOrganization(), mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })];
 
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot();
index 23a3c2cf5c6552e10ce096bddd6317e74add9b0f..025209632ceb83e590cc1b378eea8790d0552be1 100644 (file)
@@ -23,6 +23,10 @@ import { shallow } from 'enzyme';
 import RemoteRepositories from '../RemoteRepositories';
 import { getRepositories } from '../../../../api/alm-integration';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
+import {
+  mockOrganizationWithAlm,
+  mockOrganizationWithAdminActions
+} from '../../../../helpers/testMocks';
 
 jest.mock('../../../../api/alm-integration', () => ({
   getRepositories: jest.fn().mockResolvedValue({
@@ -46,12 +50,7 @@ const almApplication = {
   name: 'GitHub'
 };
 
-const organization: T.Organization = {
-  alm: { key: 'github', url: '' },
-  key: 'sonarsource',
-  name: 'SonarSource',
-  subscription: 'FREE'
-};
+const organization: T.Organization = mockOrganizationWithAlm({ subscription: 'FREE' });
 
 beforeEach(() => {
   (getRepositories as jest.Mock<any>).mockClear();
@@ -62,7 +61,7 @@ it('should display the list of repositories', async () => {
   expect(wrapper).toMatchSnapshot();
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
-  expect(getRepositories).toHaveBeenCalledWith({ organization: 'sonarsource' });
+  expect(getRepositories).toHaveBeenCalledWith({ organization: 'foo' });
 });
 
 it('should display the organization upgrade box', async () => {
@@ -81,13 +80,9 @@ it('should not display the organization upgrade box', () => {
     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'
-    }
+    organization: mockOrganizationWithAdminActions(
+      mockOrganizationWithAlm({ subscription: 'PAID' })
+    )
   });
 
   expect(wrapper.find('UpgradeOrganizationBox').exists()).toBe(false);
index 86339a2d849e2bbd53bd60aac7a6e5d026954567..a7c3844ded7c3ddc681281f613514f4a76475466 100644 (file)
@@ -22,6 +22,7 @@ import { shallow } from 'enzyme';
 import SetupProjectBox from '../SetupProjectBox';
 import { waitAndUpdate, submit } from '../../../../helpers/testUtils';
 import { provisionProject } from '../../../../api/alm-integration';
+import { mockOrganizationWithAlm } from '../../../../helpers/testMocks';
 
 jest.mock('../../../../api/alm-integration', () => ({
   provisionProject: jest
@@ -29,18 +30,6 @@ jest.mock('../../../../api/alm-integration', () => ({
     .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 });
@@ -49,11 +38,11 @@ it('should correctly create projects', async () => {
   submit(wrapper.find('form'));
   expect(provisionProject).toBeCalledWith({
     installationKeys: ['github/awesome', 'github/foo'],
-    organization: 'sonarsource'
+    organization: 'foo'
   });
 
   await waitAndUpdate(wrapper);
-  expect(onProjectCreate).toBeCalledWith(['awesome', 'foo'], 'sonarsource');
+  expect(onProjectCreate).toBeCalledWith(['awesome', 'foo'], 'foo');
 });
 
 function shallowRender(props: Partial<SetupProjectBox['props']> = {}) {
@@ -61,8 +50,11 @@ function shallowRender(props: Partial<SetupProjectBox['props']> = {}) {
     <SetupProjectBox
       onProjectCreate={jest.fn()}
       onProvisionFail={jest.fn()}
-      organization={organization}
-      selectedRepositories={selectedRepositories}
+      organization={mockOrganizationWithAlm({ subscription: 'FREE' })}
+      selectedRepositories={[
+        { label: 'Awesome Project', installationKey: 'github/awesome' },
+        { label: 'Foo', installationKey: 'github/foo', private: true }
+      ]}
       {...props}
     />
   );
index 0f3247f2994e5c36fe5cd92c2ecc592a88924289..61c5ce222bbddfad75e9e71ffc949fa13ea01aa4 100644 (file)
@@ -14,7 +14,8 @@ exports[`should display the bound organizations dropdown with the remote reposit
           },
           "alm": Object {
             "key": "github",
-            "url": "",
+            "membersSync": false,
+            "url": "https://github.com/foo",
           },
           "key": "foo",
           "name": "Foo",
@@ -23,7 +24,8 @@ exports[`should display the bound organizations dropdown with the remote reposit
         Object {
           "alm": Object {
             "key": "github",
-            "url": "",
+            "membersSync": false,
+            "url": "https://github.com/foo",
           },
           "key": "bar",
           "name": "Bar",
@@ -51,7 +53,8 @@ exports[`should display the bound organizations dropdown with the remote reposit
         },
         "alm": Object {
           "key": "github",
-          "url": "",
+          "membersSync": false,
+          "url": "https://github.com/foo",
         },
         "key": "foo",
         "name": "Foo",
index cbec17a9d1cfe14a1da8ad2fc557f8f1c52ac87b..c0407c7e630e449c23b69423e165990787eafc70 100644 (file)
@@ -81,11 +81,13 @@ exports[`should render correctly 2`] = `
         Array [
           Object {
             "actions": Object {
+              "admin": false,
               "provision": true,
             },
             "alm": Object {
               "key": "github",
-              "url": "",
+              "membersSync": false,
+              "url": "https://github.com/foo",
             },
             "key": "bar",
             "name": "Bar",
@@ -136,6 +138,7 @@ exports[`should render with Manual creation only 1`] = `
         Array [
           Object {
             "actions": Object {
+              "admin": false,
               "provision": true,
             },
             "key": "foo",
@@ -143,11 +146,13 @@ exports[`should render with Manual creation only 1`] = `
           },
           Object {
             "actions": Object {
+              "admin": false,
               "provision": true,
             },
             "alm": Object {
               "key": "github",
-              "url": "",
+              "membersSync": false,
+              "url": "https://github.com/foo",
             },
             "key": "bar",
             "name": "Bar",
@@ -211,11 +216,13 @@ exports[`should switch tabs 1`] = `
         Array [
           Object {
             "actions": Object {
+              "admin": false,
               "provision": true,
             },
             "alm": Object {
               "key": "github",
-              "url": "",
+              "membersSync": false,
+              "url": "https://github.com/foo",
             },
             "key": "bar",
             "name": "Bar",
index ba9fc2ff4507832e8d3f91d2aa73331fead63994..a655ac75dbe09a5793570148707b87a7982a8b78 100644 (file)
@@ -33,7 +33,8 @@ exports[`should render correctly 1`] = `
         Object {
           "alm": Object {
             "key": "github",
-            "url": "",
+            "membersSync": false,
+            "url": "https://github.com/foo",
           },
           "key": "bar",
           "name": "Bar",
index 60e50364c99aba774e8138631649b6fa9358e4ae..ed810b7ac31b2450613a6503ca61f00e4f9f4d73 100644 (file)
@@ -33,10 +33,11 @@ exports[`should display the list of repositories 1`] = `
           Object {
             "alm": Object {
               "key": "github",
-              "url": "",
+              "membersSync": false,
+              "url": "https://github.com/foo",
             },
-            "key": "sonarsource",
-            "name": "SonarSource",
+            "key": "foo",
+            "name": "Foo",
             "subscription": "FREE",
           }
         }
@@ -121,10 +122,11 @@ exports[`should display the list of repositories 2`] = `
           Object {
             "alm": Object {
               "key": "github",
-              "url": "",
+              "membersSync": false,
+              "url": "https://github.com/foo",
             },
-            "key": "sonarsource",
-            "name": "SonarSource",
+            "key": "foo",
+            "name": "Foo",
             "subscription": "FREE",
           }
         }
@@ -146,10 +148,11 @@ exports[`should display the organization upgrade box 1`] = `
       },
       "alm": Object {
         "key": "github",
-        "url": "",
+        "membersSync": false,
+        "url": "https://github.com/foo",
       },
-      "key": "sonarsource",
-      "name": "SonarSource",
+      "key": "foo",
+      "name": "Foo",
       "subscription": "FREE",
     }
   }
index 938e5a47d05408d59597863af3335ff8e4f0a6e7..ba7cabf81a0ff9abf4015c887220deebfb5ddae1 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import HelpTooltip from '../../components/controls/HelpTooltip';
 import SearchBox from '../../components/controls/SearchBox';
+import { getAlmMembersUrl, sanitizeAlmId } from '../../helpers/almIntegrations';
+import { translate, translateWithParameters } from '../../helpers/l10n';
 import { formatMeasure } from '../../helpers/measures';
-import { translate } from '../../helpers/l10n';
 
-interface Props {
+export interface Props {
+  currentUser: T.LoggedInUser;
   handleSearch: (query?: string) => void;
+  organization: T.Organization;
   total?: number;
 }
 
-export default function MembersListHeader({ handleSearch, total }: Props) {
+export default function MembersListHeader({
+  currentUser,
+  handleSearch,
+  organization,
+  total
+}: Props) {
   return (
     <div className="panel panel-vertical bordered-bottom spacer-bottom">
       <SearchBox
@@ -38,6 +47,38 @@ export default function MembersListHeader({ handleSearch, total }: Props) {
       {total !== undefined && (
         <span className="pull-right little-spacer-top">
           <strong>{formatMeasure(total, 'INT')}</strong> {translate('organization.members.members')}
+          {organization.alm &&
+            organization.alm.membersSync && (
+              <HelpTooltip
+                className="spacer-left"
+                overlay={
+                  <div className="abs-width-300  markdown cut-margins">
+                    <p>
+                      {translate(
+                        'organization.members.auto_sync_total_help',
+                        sanitizeAlmId(organization.alm.key) || ''
+                      )}
+                    </p>
+                    {currentUser.personalOrganization !== organization.key && (
+                      <>
+                        <hr />
+                        <p>
+                          <a
+                            href={getAlmMembersUrl(organization.alm)}
+                            rel="noopener noreferrer"
+                            target="_blank">
+                            {translateWithParameters(
+                              'organization.members.see_all_members_on_x',
+                              translate(sanitizeAlmId(organization.alm.key) || '')
+                            )}
+                          </a>
+                        </p>
+                      </>
+                    )}
+                  </div>
+                }
+              />
+            )}
         </span>
       )}
     </div>
index ec540fed3132e9f20c29450234bd84b998d8c15e..82c68eba3d6edc0ed53f69bf071a2c2807eb9d97 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { connect } from 'react-redux';
 import { FormattedMessage } from 'react-intl';
 import { Link } from 'react-router';
-import { translate } from '../../helpers/l10n';
+import AddMemberForm from './AddMemberForm';
+import SyncMemberForm from './SyncMemberForm';
 import DeferredSpinner from '../../components/common/DeferredSpinner';
+import DocTooltip from '../../components/docs/DocTooltip';
+import NewInfoBox from '../../components/ui/NewInfoBox';
+import { sanitizeAlmId } from '../../helpers/almIntegrations';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { getCurrentUserSetting, Store } from '../../store/rootReducer';
+import { setCurrentUserSetting } from '../../store/users';
 
 interface Props {
-  children?: React.ReactNode;
+  dismissSyncNotifOrg: string[];
+  handleAddMember: (member: T.OrganizationMember) => void;
   loading: boolean;
+  members?: T.OrganizationMember[];
+  organization: T.Organization;
+  setCurrentUserSetting: (setting: T.CurrentUserSetting) => void;
 }
 
-export default function MembersPageHeader(props: Props) {
-  return (
-    <header className="page-header">
-      <h1 className="page-title">{translate('organization.members.page')}</h1>
-      <DeferredSpinner loading={props.loading} />
-      {props.children}
-      <p className="page-description">
-        <FormattedMessage
-          defaultMessage={translate('organization.members.page.description')}
-          id="organization.members.page.description"
-          values={{
-            link: (
-              <Link to="/documentation/organizations/manage-team/">
-                {translate('organization.members.manage_a_team')}
-              </Link>
-            )
-          }}
-        />
-      </p>
-    </header>
-  );
+export class MembersPageHeader extends React.PureComponent<Props> {
+  handleDismissSyncNotif = () => {
+    const { dismissSyncNotifOrg, organization } = this.props;
+    this.props.setCurrentUserSetting({
+      key: 'organizations.members.dismissSyncNotif',
+      value: [...dismissSyncNotifOrg, organization.key].join(',')
+    });
+  };
+
+  render() {
+    const { dismissSyncNotifOrg, members, organization } = this.props;
+    const memberLogins = members ? members.map(member => member.login) : [];
+    const isAdmin = organization.actions && organization.actions.admin;
+    const almKey = organization.alm && sanitizeAlmId(organization.alm.key);
+    const hasMemberSync = organization.alm && organization.alm.membersSync;
+    const showSyncNotif =
+      isAdmin &&
+      organization.alm &&
+      !hasMemberSync &&
+      !dismissSyncNotifOrg.some(orgKey => orgKey === organization.key);
+
+    return (
+      <header className="page-header">
+        <h1 className="page-title">{translate('organization.members.page')}</h1>
+        <DeferredSpinner loading={this.props.loading} />
+        {isAdmin && (
+          <div className="page-actions text-right">
+            {almKey && !showSyncNotif && <SyncMemberForm organization={organization} />}
+            {!hasMemberSync && (
+              <div className="display-inline-block spacer-left spacer-bottom">
+                <AddMemberForm
+                  addMember={this.props.handleAddMember}
+                  memberLogins={memberLogins}
+                  organization={organization}
+                />
+                <DocTooltip
+                  className="spacer-left"
+                  doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')}
+                />
+              </div>
+            )}
+            {almKey &&
+              showSyncNotif && (
+                <NewInfoBox
+                  description={translate('organization.members.auto_sync_members_from_org', almKey)}
+                  onClose={this.handleDismissSyncNotif}
+                  title={translateWithParameters(
+                    'organization.members.auto_sync_with_x',
+                    translate(almKey)
+                  )}>
+                  <SyncMemberForm organization={organization} />
+                </NewInfoBox>
+              )}
+          </div>
+        )}
+        <div className="page-description">
+          <FormattedMessage
+            defaultMessage={translate('organization.members.page.description')}
+            id="organization.members.page.description"
+            values={{
+              link: (
+                <Link to="/documentation/organizations/manage-team/">
+                  {translate('organization.members.manage_a_team')}
+                </Link>
+              )
+            }}
+          />
+        </div>
+      </header>
+    );
+  }
 }
+
+const mapStateToProps = (state: Store) => ({
+  dismissSyncNotifOrg: (
+    getCurrentUserSetting(state, 'organizations.members.dismissSyncNotif') || ''
+  ).split(',')
+});
+
+const mapDispatchToProps = { setCurrentUserSetting };
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(MembersPageHeader);
index 215415f62162f6ee5b368c7e3a050f4fdea472ca..46e1162372ba579a1ffe83d7b2136b989f708468 100644 (file)
@@ -22,15 +22,14 @@ import Helmet from 'react-helmet';
 import MembersPageHeader from './MembersPageHeader';
 import MembersListHeader from './MembersListHeader';
 import MembersList from './MembersList';
-import AddMemberForm from './AddMemberForm';
 import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
 import ListFooter from '../../components/controls/ListFooter';
-import DocTooltip from '../../components/docs/DocTooltip';
 import { translate } from '../../helpers/l10n';
 import { searchMembers, addMember, removeMember } from '../../api/organizations';
 import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../api/user_groups';
 
 interface Props {
+  currentUser: T.LoggedInUser;
   organization: T.Organization;
 }
 
@@ -187,31 +186,25 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat
   render() {
     const { organization } = this.props;
     const { groups, loading, members, paging } = this.state;
-    const memberLogins = members ? members.map(member => member.login) : [];
     return (
       <div className="page page-limited">
         <Helmet title={translate('organization.members.page')} />
         <Suggestions suggestions="organization_members" />
-        <MembersPageHeader loading={loading}>
-          {organization.actions &&
-            organization.actions.admin && (
-              <div className="page-actions">
-                <AddMemberForm
-                  addMember={this.handleAddMember}
-                  memberLogins={memberLogins}
-                  organization={organization}
-                />
-                <DocTooltip
-                  className="spacer-left"
-                  doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')}
-                />
-              </div>
-            )}
-        </MembersPageHeader>
+        <MembersPageHeader
+          handleAddMember={this.handleAddMember}
+          loading={loading}
+          members={members}
+          organization={organization}
+        />
         {members !== undefined &&
           paging !== undefined && (
             <>
-              <MembersListHeader handleSearch={this.handleSearchMembers} total={paging.total} />
+              <MembersListHeader
+                currentUser={this.props.currentUser}
+                handleSearch={this.handleSearchMembers}
+                organization={organization}
+                total={paging.total}
+              />
               <MembersList
                 members={members}
                 organization={organization}
index 0390d301ea3a4893ae9a96a47d6bbcfcfad8316f..05c709f39209ab310861ee16d2231e4aef3cbdcc 100644 (file)
@@ -20,6 +20,7 @@
 import { connect } from 'react-redux';
 import OrganizationMembers from './OrganizationMembers';
 import { getOrganizationByKey, Store } from '../../store/rootReducer';
+import { withCurrentUser } from '../../components/hoc/withCurrentUser';
 
 interface OwnProps {
   params: { organizationKey: string };
@@ -33,4 +34,4 @@ const mapStateToProps = (state: Store, ownProps: OwnProps): StateProps => {
   return { organization: getOrganizationByKey(state, ownProps.params.organizationKey) };
 };
 
-export default connect(mapStateToProps)(OrganizationMembers);
+export default withCurrentUser(connect(mapStateToProps)(OrganizationMembers));
diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx
new file mode 100644 (file)
index 0000000..b04c679
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router';
+import ConfirmButton from '../../components/controls/ConfirmButton';
+import RadioCard from '../../components/controls/RadioCard';
+import { Alert } from '../../components/ui/Alert';
+import { Button } from '../../components/ui/buttons';
+import { setOrganizationMemberSync, syncMembers } from '../../api/organizations';
+import { sanitizeAlmId, isGithub } from '../../helpers/almIntegrations';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { fetchOrganization } from '../../store/rootActions';
+
+interface Props {
+  fetchOrganization: (key: string) => void;
+  organization: T.Organization;
+}
+
+interface State {
+  membersSync: boolean;
+}
+
+export class SyncMemberForm extends React.PureComponent<Props, State> {
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      membersSync: Boolean(props.organization.alm && props.organization.alm.membersSync)
+    };
+  }
+
+  handleConfirm = () => {
+    const { organization } = this.props;
+    const { membersSync } = this.state;
+    return setOrganizationMemberSync({
+      organization: organization.key,
+      enabled: membersSync
+    }).then(() => {
+      this.props.fetchOrganization(organization.key);
+      if (membersSync && isGithub(organization.alm && organization.alm.key)) {
+        return syncMembers(organization.key);
+      }
+      return Promise.resolve();
+    });
+  };
+
+  handleManualClick = () => {
+    this.setState({ membersSync: false });
+  };
+
+  handleAutoClick = () => {
+    this.setState({ membersSync: true });
+  };
+
+  renderModalBody = () => {
+    const { membersSync } = this.state;
+    const { organization } = this.props;
+    const almKey = organization.alm && sanitizeAlmId(organization.alm.key);
+    return (
+      <>
+        {translate('organization.members.management.description')}
+        <Link
+          className="spacer-left"
+          target="_blank"
+          to={{ pathname: '/documentation/organizations/manage-team/' }}>
+          {translate('learn_more')}
+        </Link>
+        <div className="display-flex-stretch big-spacer-top">
+          <RadioCard
+            onClick={this.handleManualClick}
+            selected={!membersSync}
+            title={translate('organization.members.management.manual')}>
+            <div className="spacer-left">
+              <ul className="big-spacer-left note">
+                <li className="spacer-bottom">
+                  {translate('organization.members.management.manual.add_members_manually')}
+                </li>
+                <li>
+                  {translate('organization.members.management.manual.choose_members_permissions')}
+                </li>
+              </ul>
+            </div>
+          </RadioCard>
+          <RadioCard
+            onClick={this.handleAutoClick}
+            selected={membersSync}
+            title={translateWithParameters(
+              'organization.members.management.automatic',
+              translate(almKey || '')
+            )}>
+            <div className="spacer-left">
+              <ul className="big-spacer-left note">
+                {almKey && (
+                  <>
+                    <li className="spacer-bottom">
+                      {translate(
+                        'organization.members.management.automatic.synchronized_from',
+                        almKey
+                      )}
+                    </li>
+                    <li className="spacer-bottom">
+                      {translate(
+                        'organization.members.management.automatic.members_changes_reflected',
+                        almKey
+                      )}
+                    </li>
+                  </>
+                )}
+                <li>
+                  {translate(
+                    'organization.members.management.automatic.still_choose_members_permissions'
+                  )}
+                </li>
+              </ul>
+            </div>
+            {(!organization.alm || !organization.alm.membersSync) && (
+              <Alert className="big-spacer-top" variant="warning">
+                {translate('organization.members.management.automatic.warning')}
+              </Alert>
+            )}
+          </RadioCard>
+        </div>
+      </>
+    );
+  };
+
+  render() {
+    const { organization } = this.props;
+    const orgMemberSync = Boolean(organization.alm && organization.alm.membersSync);
+    return (
+      <ConfirmButton
+        cancelButtonText={translate('close')}
+        confirmButtonText={translate('save')}
+        confirmDisable={this.state.membersSync === orgMemberSync}
+        medium={true}
+        modalBody={this.renderModalBody()}
+        modalHeader={translate('organization.members.management.title')}
+        onConfirm={this.handleConfirm}>
+        {({ onClick }) => (
+          <Button onClick={onClick}>{translate('organization.members.config_synchro')}</Button>
+        )}
+      </ConfirmButton>
+    );
+  }
+}
+
+const mapDispatchToProps = { fetchOrganization };
+
+export default connect(
+  null,
+  mapDispatchToProps
+)(SyncMemberForm);
index 3105927acfa26133b5d2a657d2bf1ebb97f3c80b..7c97806d07e3808fd60a2b01085dd8eb77c884ac 100644 (file)
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import MembersListHeader from '../MembersListHeader';
+import MembersListHeader, { Props } from '../MembersListHeader';
+import {
+  mockOrganization,
+  mockCurrentUser,
+  mockOrganizationWithAlm
+} from '../../../helpers/testMocks';
 
 it('should render without the total', () => {
-  const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} />);
-  expect(wrapper).toMatchSnapshot();
+  expect(shallowRender({ total: undefined })).toMatchSnapshot();
 });
 
 it('should render with the total', () => {
-  const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} total={8} />);
-  expect(wrapper).toMatchSnapshot();
+  expect(shallowRender()).toMatchSnapshot();
 });
+
+it('should render a help tooltip', () => {
+  expect(
+    shallowRender({ organization: mockOrganizationWithAlm({}, { membersSync: true }) }).find(
+      'HelpTooltip'
+    )
+  ).toMatchSnapshot();
+  expect(
+    shallowRender({
+      organization: mockOrganizationWithAlm(
+        {},
+        { key: 'bitbucket', membersSync: true, url: 'https://bitbucket.com/foo' }
+      )
+    }).find('HelpTooltip')
+  ).toMatchSnapshot();
+});
+
+it('should not render link in help tooltip', () => {
+  expect(
+    shallowRender({
+      currentUser: mockCurrentUser({ personalOrganization: 'foo' }),
+      organization: mockOrganizationWithAlm({}, { membersSync: true })
+    }).find('HelpTooltip')
+  ).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Props> = {}) {
+  return shallow(
+    <MembersListHeader
+      currentUser={mockCurrentUser()}
+      handleSearch={jest.fn()}
+      organization={mockOrganization()}
+      total={8}
+      {...props}
+    />
+  );
+}
index 538d093be886b7751f278a6a1370d77e84c1109d..7f74a8eff3c0ddca0ca570cc922607dede06d9b5 100644 (file)
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import MembersPageHeader from '../MembersPageHeader';
+import { MembersPageHeader } from '../MembersPageHeader';
+import {
+  mockOrganization,
+  mockOrganizationWithAlm,
+  mockOrganizationWithAdminActions
+} from '../../../helpers/testMocks';
 
-it('should render', () => {
-  const wrapper = shallow(
-    <MembersPageHeader loading={true}>
-      <span>children test</span>
-    </MembersPageHeader>
-  );
-  expect(wrapper).toMatchSnapshot();
+it('should render correctly', () => {
+  expect(shallowRender({ loading: true })).toMatchSnapshot();
+});
+
+it('should render for admin', () => {
+  expect(
+    shallowRender({ organization: mockOrganization({ actions: { admin: true } }) })
+  ).toMatchSnapshot();
+});
+
+it('should render for bound organization without sync', () => {
+  const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions());
+  expect(shallowRender({ organization })).toMatchSnapshot();
+
+  const wrapper = shallowRender({ organization, dismissSyncNotifOrg: [organization.key] });
+  expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true);
+  expect(wrapper.find('NewInfoBox').exists()).toBe(false);
+});
+
+it('should render for bound organization with sync', () => {
+  const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions(), {
+    membersSync: true
+  });
+  const wrapper = shallowRender({ organization });
+  expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true);
+  expect(wrapper.find('AddMemberForm').exists()).toBe(false);
+  expect(wrapper.find('NewInfoBox').exists()).toBe(false);
 });
+
+function shallowRender(props: Partial<MembersPageHeader['props']> = {}) {
+  return shallow(
+    <MembersPageHeader
+      dismissSyncNotifOrg={[]}
+      handleAddMember={jest.fn()}
+      loading={false}
+      members={[]}
+      organization={mockOrganization()}
+      setCurrentUserSetting={jest.fn()}
+      {...props}
+    />
+  );
+}
index 43c07605766d95a5457cc04b5ea6b52589c35d65..7861581aa2e8c3b1d3db01d939d40f8ad72fca86 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import OrganizationMembers from '../OrganizationMembers';
-import { waitAndUpdate } from '../../../helpers/testUtils';
 import { searchMembers, addMember, removeMember } from '../../../api/organizations';
 import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
+import {
+  mockOrganization,
+  mockCurrentUser,
+  mockOrganizationWithAdminActions
+} from '../../../helpers/testMocks';
+import { waitAndUpdate } from '../../../helpers/testUtils';
 
 jest.mock('../../../api/organizations', () => ({
   addMember: jest.fn().mockResolvedValue({ login: 'bar', name: 'Bar', groupCount: 1 }),
@@ -48,15 +53,13 @@ jest.mock('../../../api/user_groups', () => ({
   })
 }));
 
-const organization = { key: 'foo', name: 'Foo' };
-
 beforeEach(() => {
   (searchMembers as jest.Mock).mockClear();
   (searchUsersGroups as jest.Mock).mockClear();
 });
 
 it('should fetch members and render for non-admin', async () => {
-  const wrapper = shallow(<OrganizationMembers organization={organization} />);
+  const wrapper = shallowRender({ organization: mockOrganization() });
   expect(wrapper).toMatchSnapshot();
 
   await waitAndUpdate(wrapper);
@@ -64,40 +67,31 @@ it('should fetch members and render for non-admin', async () => {
   expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined });
 });
 
-it('should fetch members and groups and render for admin', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+it('should fetch members and groups for admin', async () => {
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
   expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined });
   expect(searchUsersGroups).toBeCalledWith({ organization: 'foo' });
 });
 
 it('should search users', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   wrapper.find('MembersListHeader').prop<Function>('handleSearch')('user');
   expect(searchMembers).lastCalledWith({ organization: 'foo', ps: 50, q: 'user' });
 });
 
 it('should load more members', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   wrapper.find('ListFooter').prop<Function>('loadMore')();
   expect(searchMembers).lastCalledWith({ organization: 'foo', p: 2, ps: 50, q: undefined });
 });
 
 it('should add new member', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
-  wrapper.find('AddMemberForm').prop<Function>('addMember')({ login: 'bar' });
+  wrapper.find('Connect(MembersPageHeader)').prop<Function>('handleAddMember')({ login: 'bar' });
   await waitAndUpdate(wrapper);
   expect(
     wrapper
@@ -110,9 +104,7 @@ it('should add new member', async () => {
 });
 
 it('should remove member', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   wrapper.find('MembersList').prop<Function>('removeMember')({ login: 'john' });
   await waitAndUpdate(wrapper);
@@ -127,9 +119,7 @@ it('should remove member', async () => {
 });
 
 it('should update groups', async () => {
-  const wrapper = shallow(
-    <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   wrapper.find('MembersList').prop<Function>('updateMemberGroups')(
     { login: 'john' },
@@ -148,3 +138,13 @@ it('should update groups', async () => {
   expect(removeUserFromGroup).toHaveBeenCalledTimes(1);
   expect(removeUserFromGroup).toBeCalledWith({ login: 'john', name: 'birds', organization: 'foo' });
 });
+
+function shallowRender(props: Partial<OrganizationMembers['props']> = {}) {
+  return shallow(
+    <OrganizationMembers
+      currentUser={mockCurrentUser()}
+      organization={mockOrganizationWithAdminActions()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx
new file mode 100644 (file)
index 0000000..4410d45
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { SyncMemberForm } from '../SyncMemberForm';
+import { setOrganizationMemberSync, syncMembers } from '../../../api/organizations';
+import { mockOrganizationWithAlm } from '../../../helpers/testMocks';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+
+jest.mock('../../../api/organizations', () => ({
+  setOrganizationMemberSync: jest.fn().mockResolvedValue(undefined),
+  syncMembers: jest.fn().mockResolvedValue(undefined)
+}));
+
+beforeEach(() => {
+  (setOrganizationMemberSync as jest.Mock).mockClear();
+  (syncMembers as jest.Mock).mockClear();
+});
+
+it('should allow to switch to automatic mode with github', async () => {
+  const fetchOrganization = jest.fn();
+  const wrapper = shallowRender({ fetchOrganization });
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ membersSync: true });
+  wrapper.find('ConfirmButton').prop<Function>('onConfirm')();
+  expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true });
+
+  await waitAndUpdate(wrapper);
+  expect(fetchOrganization).toHaveBeenCalledWith('foo');
+  expect(syncMembers).toHaveBeenCalledWith('foo');
+});
+
+it('should allow to switch to automatic mode with bitbucket', async () => {
+  const fetchOrganization = jest.fn();
+  const wrapper = shallowRender({
+    fetchOrganization,
+    organization: mockOrganizationWithAlm({}, { key: 'bitbucket' })
+  });
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ membersSync: true });
+  wrapper.find('ConfirmButton').prop<Function>('onConfirm')();
+  expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true });
+
+  await waitAndUpdate(wrapper);
+  expect(fetchOrganization).toHaveBeenCalledWith('foo');
+  expect(syncMembers).not.toHaveBeenCalled();
+});
+
+it('should allow to switch to manual mode', async () => {
+  const fetchOrganization = jest.fn();
+  const wrapper = shallowRender({
+    fetchOrganization,
+    organization: mockOrganizationWithAlm({}, { membersSync: true })
+  });
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ membersSync: false });
+  wrapper.find('ConfirmButton').prop<Function>('onConfirm')();
+  expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: false });
+
+  await waitAndUpdate(wrapper);
+  expect(fetchOrganization).toHaveBeenCalledWith('foo');
+  expect(syncMembers).not.toHaveBeenCalled();
+});
+
+function shallowRender(props: Partial<SyncMemberForm['props']> = {}) {
+  return shallow<SyncMemberForm>(
+    <SyncMemberForm
+      fetchOrganization={jest.fn()}
+      organization={mockOrganizationWithAlm()}
+      {...props}
+    />
+  );
+}
index 7bfe2725e60724a8893c42eaed57bdd18166f536..1d8ecfb92df1e0124b1e50c8647ed44234659ebe 100644 (file)
@@ -1,5 +1,74 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`should not render link in help tooltip 1`] = `
+<HelpTooltip
+  className="spacer-left"
+  overlay={
+    <div
+      className="abs-width-300  markdown cut-margins"
+    >
+      <p>
+        organization.members.auto_sync_total_help.github
+      </p>
+    </div>
+  }
+/>
+`;
+
+exports[`should render a help tooltip 1`] = `
+<HelpTooltip
+  className="spacer-left"
+  overlay={
+    <div
+      className="abs-width-300  markdown cut-margins"
+    >
+      <p>
+        organization.members.auto_sync_total_help.github
+      </p>
+      <React.Fragment>
+        <hr />
+        <p>
+          <a
+            href="https://github.com/orgs/foo/people"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            organization.members.see_all_members_on_x.github
+          </a>
+        </p>
+      </React.Fragment>
+    </div>
+  }
+/>
+`;
+
+exports[`should render a help tooltip 2`] = `
+<HelpTooltip
+  className="spacer-left"
+  overlay={
+    <div
+      className="abs-width-300  markdown cut-margins"
+    >
+      <p>
+        organization.members.auto_sync_total_help.bitbucket
+      </p>
+      <React.Fragment>
+        <hr />
+        <p>
+          <a
+            href="https://bitbucket.com/foo/profile/members"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            organization.members.see_all_members_on_x.bitbucket
+          </a>
+        </p>
+      </React.Fragment>
+    </div>
+  }
+/>
+`;
+
 exports[`should render with the total 1`] = `
 <div
   className="panel panel-vertical bordered-bottom spacer-bottom"
index 95179079487ffeb79e7e38b7fb1a18ea9c2200d6..b0bc7e4a0096ba3bc263c49f87b072ec41cbefde 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render 1`] = `
+exports[`should render correctly 1`] = `
 <header
   className="page-header"
 >
@@ -13,10 +13,7 @@ exports[`should render 1`] = `
     loading={true}
     timeout={100}
   />
-  <span>
-    children test
-  </span>
-  <p
+  <div
     className="page-description"
   >
     <FormattedMessage
@@ -34,6 +31,153 @@ exports[`should render 1`] = `
         }
       }
     />
-  </p>
+  </div>
+</header>
+`;
+
+exports[`should render for admin 1`] = `
+<header
+  className="page-header"
+>
+  <h1
+    className="page-title"
+  >
+    organization.members.page
+  </h1>
+  <DeferredSpinner
+    loading={false}
+    timeout={100}
+  />
+  <div
+    className="page-actions text-right"
+  >
+    <div
+      className="display-inline-block spacer-left spacer-bottom"
+    >
+      <AddMemberForm
+        addMember={[MockFunction]}
+        memberLogins={Array []}
+        organization={
+          Object {
+            "actions": Object {
+              "admin": true,
+            },
+            "key": "foo",
+            "name": "Foo",
+          }
+        }
+      />
+      <DocTooltip
+        className="spacer-left"
+        doc={Promise {}}
+      />
+    </div>
+  </div>
+  <div
+    className="page-description"
+  >
+    <FormattedMessage
+      defaultMessage="organization.members.page.description"
+      id="organization.members.page.description"
+      values={
+        Object {
+          "link": <Link
+            onlyActiveOnIndex={false}
+            style={Object {}}
+            to="/documentation/organizations/manage-team/"
+          >
+            organization.members.manage_a_team
+          </Link>,
+        }
+      }
+    />
+  </div>
+</header>
+`;
+
+exports[`should render for bound organization without sync 1`] = `
+<header
+  className="page-header"
+>
+  <h1
+    className="page-title"
+  >
+    organization.members.page
+  </h1>
+  <DeferredSpinner
+    loading={false}
+    timeout={100}
+  />
+  <div
+    className="page-actions text-right"
+  >
+    <div
+      className="display-inline-block spacer-left spacer-bottom"
+    >
+      <AddMemberForm
+        addMember={[MockFunction]}
+        memberLogins={Array []}
+        organization={
+          Object {
+            "actions": Object {
+              "admin": true,
+            },
+            "alm": Object {
+              "key": "github",
+              "membersSync": false,
+              "url": "https://github.com/foo",
+            },
+            "key": "foo",
+            "name": "Foo",
+          }
+        }
+      />
+      <DocTooltip
+        className="spacer-left"
+        doc={Promise {}}
+      />
+    </div>
+    <NewInfoBox
+      description="organization.members.auto_sync_members_from_org.github"
+      onClose={[Function]}
+      title="organization.members.auto_sync_with_x.github"
+    >
+      <Connect(SyncMemberForm)
+        organization={
+          Object {
+            "actions": Object {
+              "admin": true,
+            },
+            "alm": Object {
+              "key": "github",
+              "membersSync": false,
+              "url": "https://github.com/foo",
+            },
+            "key": "foo",
+            "name": "Foo",
+          }
+        }
+      />
+    </NewInfoBox>
+  </div>
+  <div
+    className="page-description"
+  >
+    <FormattedMessage
+      defaultMessage="organization.members.page.description"
+      id="organization.members.page.description"
+      values={
+        Object {
+          "link": <Link
+            onlyActiveOnIndex={false}
+            style={Object {}}
+            to="/documentation/organizations/manage-team/"
+          >
+            organization.members.manage_a_team
+          </Link>,
+        }
+      }
+    />
+  </div>
 </header>
 `;
index 90b44168efcbe9e820967d833c194cc3ce993b9a..dd26a5928f4f192467332279ad23c072a2b44fc7 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should fetch members and groups and render for admin 1`] = `
+exports[`should fetch members and render for non-admin 1`] = `
 <div
   className="page page-limited"
 >
@@ -12,110 +12,15 @@ exports[`should fetch members and groups and render for admin 1`] = `
   <Suggestions
     suggestions="organization_members"
   />
-  <MembersPageHeader
-    loading={false}
-  >
-    <div
-      className="page-actions"
-    >
-      <AddMemberForm
-        addMember={[Function]}
-        memberLogins={
-          Array [
-            "admin",
-            "john",
-          ]
-        }
-        organization={
-          Object {
-            "actions": Object {
-              "admin": true,
-            },
-            "key": "foo",
-            "name": "Foo",
-          }
-        }
-      />
-      <DocTooltip
-        className="spacer-left"
-        doc={Promise {}}
-      />
-    </div>
-  </MembersPageHeader>
-  <MembersListHeader
-    handleSearch={[Function]}
-    total={3}
-  />
-  <MembersList
-    members={
-      Array [
-        Object {
-          "avatar": "",
-          "groupCount": 3,
-          "login": "admin",
-          "name": "Admin Istrator",
-        },
-        Object {
-          "avatar": "7daf6c79d4802916d83f6266e24850af",
-          "groupCount": 1,
-          "login": "john",
-          "name": "John Doe",
-        },
-      ]
-    }
+  <Connect(MembersPageHeader)
+    handleAddMember={[Function]}
+    loading={true}
     organization={
       Object {
-        "actions": Object {
-          "admin": true,
-        },
         "key": "foo",
         "name": "Foo",
       }
     }
-    organizationGroups={
-      Array [
-        Object {
-          "default": true,
-          "description": "",
-          "id": 1,
-          "membersCount": 2,
-          "name": "Members",
-        },
-        Object {
-          "default": false,
-          "description": "",
-          "id": 2,
-          "membersCount": 0,
-          "name": "Watchers",
-        },
-      ]
-    }
-    removeMember={[Function]}
-    updateMemberGroups={[Function]}
-  />
-  <ListFooter
-    count={2}
-    loadMore={[Function]}
-    ready={true}
-    total={3}
-  />
-</div>
-`;
-
-exports[`should fetch members and render for non-admin 1`] = `
-<div
-  className="page page-limited"
->
-  <HelmetWrapper
-    defer={true}
-    encodeSpecialCharacters={true}
-    title="organization.members.page"
-  />
-  <Suggestions
-    suggestions="organization_members"
-  />
-  <MembersPageHeader
-    loading={true}
   />
 </div>
 `;
@@ -132,11 +37,49 @@ exports[`should fetch members and render for non-admin 2`] = `
   <Suggestions
     suggestions="organization_members"
   />
-  <MembersPageHeader
+  <Connect(MembersPageHeader)
+    handleAddMember={[Function]}
     loading={false}
+    members={
+      Array [
+        Object {
+          "avatar": "",
+          "groupCount": 3,
+          "login": "admin",
+          "name": "Admin Istrator",
+        },
+        Object {
+          "avatar": "7daf6c79d4802916d83f6266e24850af",
+          "groupCount": 1,
+          "login": "john",
+          "name": "John Doe",
+        },
+      ]
+    }
+    organization={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+      }
+    }
   />
   <MembersListHeader
+    currentUser={
+      Object {
+        "groups": Array [],
+        "isLoggedIn": true,
+        "login": "luke",
+        "name": "Skywalker",
+        "scmAccounts": Array [],
+      }
+    }
     handleSearch={[Function]}
+    organization={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+      }
+    }
     total={3}
   />
   <MembersList
diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..b8744db
--- /dev/null
@@ -0,0 +1,271 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should allow to switch to automatic mode with bitbucket 1`] = `
+<ConfirmButton
+  cancelButtonText="close"
+  confirmButtonText="save"
+  confirmDisable={true}
+  medium={true}
+  modalBody={
+    <React.Fragment>
+      organization.members.management.description
+      <Link
+        className="spacer-left"
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        target="_blank"
+        to={
+          Object {
+            "pathname": "/documentation/organizations/manage-team/",
+          }
+        }
+      >
+        learn_more
+      </Link>
+      <div
+        className="display-flex-stretch big-spacer-top"
+      >
+        <RadioCard
+          onClick={[Function]}
+          selected={true}
+          title="organization.members.management.manual"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <li
+                className="spacer-bottom"
+              >
+                organization.members.management.manual.add_members_manually
+              </li>
+              <li>
+                organization.members.management.manual.choose_members_permissions
+              </li>
+            </ul>
+          </div>
+        </RadioCard>
+        <RadioCard
+          onClick={[Function]}
+          selected={false}
+          title="organization.members.management.automatic.bitbucket"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <React.Fragment>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.synchronized_from.bitbucket
+                </li>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.members_changes_reflected.bitbucket
+                </li>
+              </React.Fragment>
+              <li>
+                organization.members.management.automatic.still_choose_members_permissions
+              </li>
+            </ul>
+          </div>
+          <Alert
+            className="big-spacer-top"
+            variant="warning"
+          >
+            organization.members.management.automatic.warning
+          </Alert>
+        </RadioCard>
+      </div>
+    </React.Fragment>
+  }
+  modalHeader="organization.members.management.title"
+  onConfirm={[Function]}
+>
+  <Component />
+</ConfirmButton>
+`;
+
+exports[`should allow to switch to automatic mode with github 1`] = `
+<ConfirmButton
+  cancelButtonText="close"
+  confirmButtonText="save"
+  confirmDisable={true}
+  medium={true}
+  modalBody={
+    <React.Fragment>
+      organization.members.management.description
+      <Link
+        className="spacer-left"
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        target="_blank"
+        to={
+          Object {
+            "pathname": "/documentation/organizations/manage-team/",
+          }
+        }
+      >
+        learn_more
+      </Link>
+      <div
+        className="display-flex-stretch big-spacer-top"
+      >
+        <RadioCard
+          onClick={[Function]}
+          selected={true}
+          title="organization.members.management.manual"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <li
+                className="spacer-bottom"
+              >
+                organization.members.management.manual.add_members_manually
+              </li>
+              <li>
+                organization.members.management.manual.choose_members_permissions
+              </li>
+            </ul>
+          </div>
+        </RadioCard>
+        <RadioCard
+          onClick={[Function]}
+          selected={false}
+          title="organization.members.management.automatic.github"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <React.Fragment>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.synchronized_from.github
+                </li>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.members_changes_reflected.github
+                </li>
+              </React.Fragment>
+              <li>
+                organization.members.management.automatic.still_choose_members_permissions
+              </li>
+            </ul>
+          </div>
+          <Alert
+            className="big-spacer-top"
+            variant="warning"
+          >
+            organization.members.management.automatic.warning
+          </Alert>
+        </RadioCard>
+      </div>
+    </React.Fragment>
+  }
+  modalHeader="organization.members.management.title"
+  onConfirm={[Function]}
+>
+  <Component />
+</ConfirmButton>
+`;
+
+exports[`should allow to switch to manual mode 1`] = `
+<ConfirmButton
+  cancelButtonText="close"
+  confirmButtonText="save"
+  confirmDisable={true}
+  medium={true}
+  modalBody={
+    <React.Fragment>
+      organization.members.management.description
+      <Link
+        className="spacer-left"
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        target="_blank"
+        to={
+          Object {
+            "pathname": "/documentation/organizations/manage-team/",
+          }
+        }
+      >
+        learn_more
+      </Link>
+      <div
+        className="display-flex-stretch big-spacer-top"
+      >
+        <RadioCard
+          onClick={[Function]}
+          selected={false}
+          title="organization.members.management.manual"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <li
+                className="spacer-bottom"
+              >
+                organization.members.management.manual.add_members_manually
+              </li>
+              <li>
+                organization.members.management.manual.choose_members_permissions
+              </li>
+            </ul>
+          </div>
+        </RadioCard>
+        <RadioCard
+          onClick={[Function]}
+          selected={true}
+          title="organization.members.management.automatic.github"
+        >
+          <div
+            className="spacer-left"
+          >
+            <ul
+              className="big-spacer-left note"
+            >
+              <React.Fragment>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.synchronized_from.github
+                </li>
+                <li
+                  className="spacer-bottom"
+                >
+                  organization.members.management.automatic.members_changes_reflected.github
+                </li>
+              </React.Fragment>
+              <li>
+                organization.members.management.automatic.still_choose_members_permissions
+              </li>
+            </ul>
+          </div>
+        </RadioCard>
+      </div>
+    </React.Fragment>
+  }
+  modalHeader="organization.members.management.title"
+  onConfirm={[Function]}
+>
+  <Component />
+</ConfirmButton>
+`;
index 7df405179951e0f4a7555e6fda6ebed096311227..39519ffd93927d12c513376e9580990c392d0acd 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import OrganizationNavigationHeader from '../OrganizationNavigationHeader';
+import { mockOrganizationWithAlm } from '../../../../helpers/testMocks';
 
 it('renders', () => {
   expect(
@@ -36,12 +37,7 @@ it('renders with alm integration', () => {
   expect(
     shallow(
       <OrganizationNavigationHeader
-        organization={{
-          alm: { key: 'github', url: 'https://github.com/foo' },
-          key: 'foo',
-          name: 'Foo',
-          projectVisibility: 'public'
-        }}
+        organization={mockOrganizationWithAlm({ projectVisibility: 'public' })}
         organizations={[]}
       />
     )
index 5ef064c39fc5d768f293d528104c11683114f008..c02c0831a7f8a751c65299d1642eb90dbedc0eee 100644 (file)
@@ -76,6 +76,7 @@ exports[`renders with alm integration 1`] = `
       Object {
         "alm": Object {
           "key": "github",
+          "membersSync": false,
           "url": "https://github.com/foo",
         },
         "key": "foo",
index c3be977b72d14e60d6add81bbcfe57e926a8a449..0718757ff1ac634503539ede94d111a703e45a35 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import ConfirmModal, { ConfirmModalProps } from './ConfirmModal';
 import ModalButton, { ChildrenProps, ModalProps } from './ModalButton';
-import ConfirmModal from './ConfirmModal';
 
-export { ChildrenProps } from './ModalButton';
-
-interface Props {
+interface Props<T> extends ConfirmModalProps<T> {
   children: (props: ChildrenProps) => React.ReactNode;
-  cancelButtonText?: string;
-  confirmButtonText: string;
-  confirmData?: string;
-  confirmDisable?: boolean;
-  isDestructive?: boolean;
   modalBody: React.ReactNode;
   modalHeader: string;
-  onConfirm: (data?: string) => void | Promise<void>;
 }
 
 interface State {
   modal: boolean;
 }
 
-export default class ConfirmButton extends React.PureComponent<Props, State> {
+export default class ConfirmButton<T> extends React.PureComponent<Props<T>, State> {
   renderConfirmModal = ({ onClose }: ModalProps) => {
+    const { children, modalBody, modalHeader, ...confirmModalProps } = this.props;
     return (
-      <ConfirmModal
-        cancelButtonText={this.props.cancelButtonText}
-        confirmButtonText={this.props.confirmButtonText}
-        confirmData={this.props.confirmData}
-        confirmDisable={this.props.confirmDisable}
-        header={this.props.modalHeader}
-        isDestructive={this.props.isDestructive}
-        onClose={onClose}
-        onConfirm={this.props.onConfirm}>
-        {this.props.modalBody}
+      <ConfirmModal header={modalHeader} onClose={onClose} {...confirmModalProps}>
+        {modalBody}
       </ConfirmModal>
     );
   };
index 8ac88d222acb7611e8ac8b5a3f1ec5af432198fe..f51f9704aecf9d2e233ea780b85806a88f434b56 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { ModalProps } from './Modal';
 import SimpleModal, { ChildrenProps } from './SimpleModal';
 import DeferredSpinner from '../common/DeferredSpinner';
-import { translate } from '../../helpers/l10n';
 import { SubmitButton, ResetButtonLink } from '../ui/buttons';
+import { translate } from '../../helpers/l10n';
 
-interface Props<T> {
-  children: React.ReactNode;
+export interface ConfirmModalProps<T> extends ModalProps {
   cancelButtonText?: string;
   confirmButtonText: string;
   confirmData?: T;
   confirmDisable?: boolean;
-  header: string;
   isDestructive?: boolean;
+  onConfirm: (data?: T) => void | Promise<void | Response>;
+}
+
+interface Props<T> extends ConfirmModalProps<T> {
+  header: string;
   onClose: () => void;
-  onConfirm: (data?: T) => void | Promise<void>;
 }
 
 export default class ConfirmModal<T = string> extends React.PureComponent<Props<T>> {
@@ -82,9 +85,10 @@ export default class ConfirmModal<T = string> extends React.PureComponent<Props<
   };
 
   render() {
-    const { header } = this.props;
+    const { header, onClose, medium, noBackdrop, large, simple } = this.props;
+    const modalProps = { header, onClose, medium, noBackdrop, large, simple };
     return (
-      <SimpleModal header={header} onClose={this.props.onClose} onSubmit={this.handleSubmit}>
+      <SimpleModal onSubmit={this.handleSubmit} {...modalProps}>
         {this.renderModalContent}
       </SimpleModal>
     );
index 7f91bb4e0823ffc7789844ce214106b4d944d07d..e7b14c3a1dfc67bba31a5a004886f1b6b000674f 100644 (file)
@@ -23,7 +23,8 @@ import * as classNames from 'classnames';
 
 ReactModal.setAppElement('#content');
 
-interface OwnProps {
+export interface ModalProps {
+  children: React.ReactNode;
   medium?: boolean;
   noBackdrop?: boolean;
   large?: boolean;
@@ -32,7 +33,7 @@ interface OwnProps {
 
 type MandatoryProps = Pick<ReactModal.Props, 'contentLabel'>;
 
-type Props = Partial<ReactModal.Props> & MandatoryProps & OwnProps;
+type Props = Partial<ReactModal.Props> & MandatoryProps & ModalProps;
 
 export default function Modal(props: Props) {
   return (
diff --git a/server/sonar-web/src/main/js/components/controls/RadioCard.css b/server/sonar-web/src/main/js/components/controls/RadioCard.css
new file mode 100644 (file)
index 0000000..a5099a9
--- /dev/null
@@ -0,0 +1,115 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+.radio-card {
+  display: flex;
+  flex-direction: column;
+  width: 450px;
+  min-height: 210px;
+  background-color: #fff;
+  border: solid 1px var(--barBorderColor);
+  border-radius: 3px;
+  box-sizing: border-box;
+  margin-right: calc(2 * var(--gridSize));
+  transition: all 0.2s ease;
+}
+
+.radio-card.animated {
+  height: 0;
+  border-width: 0;
+  overflow: hidden;
+}
+
+.radio-card.animated.open {
+  height: 210px;
+  border-width: 1px;
+}
+
+.radio-card.highlight {
+  box-shadow: var(--defaultShadow);
+}
+
+.radio-card:last-child {
+  margin-right: 0;
+}
+
+.radio-card:focus {
+  outline: none;
+}
+
+.radio-card-actionable {
+  cursor: pointer;
+}
+
+.radio-card-actionable:not(.disabled):hover {
+  box-shadow: var(--defaultShadow);
+  transform: translateY(-2px);
+}
+
+.radio-card-actionable.selected {
+  border-color: var(--darkBlue);
+}
+
+.radio-card-actionable.selected .radio-card-recommended {
+  border: solid 1px var(--darkBlue);
+  border-top: none;
+}
+
+.radio-card-actionable.disabled {
+  cursor: not-allowed;
+  background-color: var(--disableGrayBg);
+  border-color: var(--disableGrayBorder);
+}
+
+.radio-card-actionable.disabled h2,
+.radio-card-actionable.disabled ul {
+  color: var(--disableGrayText);
+}
+
+.radio-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) 0;
+}
+
+.radio-card-body {
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 0 calc(2 * var(--gridSize)) calc(2 * var(--gridSize));
+}
+
+.radio-card-body .alert {
+  margin-bottom: 0;
+}
+
+.radio-card-recommended {
+  position: relative;
+  padding: 6px calc(var(--gridSize) * 2);
+  left: -1px;
+  bottom: -1px;
+  width: 450px;
+  color: #fff;
+  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/components/controls/RadioCard.tsx b/server/sonar-web/src/main/js/components/controls/RadioCard.tsx
new file mode 100644 (file)
index 0000000..9672636
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 RecommendedIcon from '../icons-components/RecommendedIcon';
+import { translate } from '../../helpers/l10n';
+import './RadioCard.css';
+
+export interface RadioCardProps {
+  className?: string;
+  disabled?: boolean;
+  onClick?: () => void;
+  selected?: boolean;
+}
+
+interface Props extends RadioCardProps {
+  children: React.ReactNode;
+  recommended?: string;
+  title: React.ReactNode;
+  titleInfo?: React.ReactNode;
+}
+
+export default function RadioCard(props: Props) {
+  const { className, disabled, onClick, recommended, selected, titleInfo } = props;
+  const isActionable = Boolean(onClick);
+  return (
+    <div
+      aria-checked={selected}
+      className={classNames(
+        'radio-card',
+        { 'radio-card-actionable': isActionable, disabled, selected },
+        className
+      )}
+      onClick={isActionable && !disabled ? onClick : undefined}
+      role="radio"
+      tabIndex={0}>
+      <h2 className="radio-card-header big-spacer-bottom">
+        <span className="display-flex-center">
+          {isActionable && (
+            <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': selected })} />
+          )}
+          {props.title}
+        </span>
+        {titleInfo}
+      </h2>
+      <div className="radio-card-body">{props.children}</div>
+      {recommended && (
+        <div className="radio-card-recommended">
+          <RecommendedIcon className="spacer-right" />
+          <FormattedMessage
+            defaultMessage={recommended}
+            id={recommended}
+            values={{ recommended: <strong>{translate('recommended')}</strong> }}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
index a8a1ba1fe2dd32709bd46ae1de7968091cc6092c..9870f0e6ec7db2128a77a6adcfe9841f54109426 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import Modal from './Modal';
+import Modal, { ModalProps } from './Modal';
 
 export interface ChildrenProps {
   onCloseClick: (event?: React.SyntheticEvent<HTMLElement>) => void;
@@ -27,7 +27,7 @@ export interface ChildrenProps {
   submitting: boolean;
 }
 
-interface Props {
+interface Props extends ModalProps {
   children: (props: ChildrenProps) => React.ReactNode;
   header: string;
   onClose: () => void;
@@ -86,9 +86,10 @@ export default class SimpleModal extends React.Component<Props, State> {
   };
 
   render() {
+    const { children, header, onClose, onSubmit, ...modalProps } = this.props;
     return (
-      <Modal contentLabel={this.props.header} onRequestClose={this.props.onClose}>
-        {this.props.children({
+      <Modal contentLabel={header} onRequestClose={onClose} {...modalProps}>
+        {children({
           onCloseClick: this.handleCloseClick,
           onFormSubmit: this.handleFormSubmit,
           onSubmitClick: this.handleSubmitClick,
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/RadioCard-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/RadioCard-test.tsx
new file mode 100644 (file)
index 0000000..da11ea3
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 RadioCard from '../RadioCard';
+import { click } from '../../../helpers/testUtils';
+
+it('should render correctly', () => {
+  expect(
+    shallow(
+      <RadioCard recommended="Recommended for you" title="Radio Card" titleInfo="info">
+        <div>content</div>
+      </RadioCard>
+    )
+  ).toMatchSnapshot();
+});
+
+it('should be actionable', () => {
+  const onClick = jest.fn();
+  const wrapper = shallow(
+    <RadioCard onClick={onClick} title="Radio Card">
+      <div>content</div>
+    </RadioCard>
+  );
+
+  expect(wrapper).toMatchSnapshot();
+  click(wrapper);
+  wrapper.setProps({ selected: true, titleInfo: 'info' });
+  expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap
new file mode 100644 (file)
index 0000000..4394ef5
--- /dev/null
@@ -0,0 +1,128 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should be actionable 1`] = `
+<div
+  className="radio-card radio-card-actionable"
+  onClick={[MockFunction]}
+  role="radio"
+  tabIndex={0}
+>
+  <h2
+    className="radio-card-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      <i
+        className="icon-radio spacer-right"
+      />
+      Radio Card
+    </span>
+  </h2>
+  <div
+    className="radio-card-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should be actionable 2`] = `
+<div
+  aria-checked={true}
+  className="radio-card radio-card-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="radio-card-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      <i
+        className="icon-radio spacer-right is-checked"
+      />
+      Radio Card
+    </span>
+    info
+  </h2>
+  <div
+    className="radio-card-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+  className="radio-card"
+  role="radio"
+  tabIndex={0}
+>
+  <h2
+    className="radio-card-header big-spacer-bottom"
+  >
+    <span
+      className="display-flex-center"
+    >
+      Radio Card
+    </span>
+    info
+  </h2>
+  <div
+    className="radio-card-body"
+  >
+    <div>
+      content
+    </div>
+  </div>
+  <div
+    className="radio-card-recommended"
+  >
+    <RecommendedIcon
+      className="spacer-right"
+    />
+    <FormattedMessage
+      defaultMessage="Recommended for you"
+      id="Recommended for you"
+      values={
+        Object {
+          "recommended": <strong>
+            recommended
+          </strong>,
+        }
+      }
+    />
+  </div>
+</div>
+`;
index e324bcf7757b770664f17d97210fcf412e3c5763..6548cae6a21771bb3b98acaeef3dd16d64bb5330 100644 (file)
@@ -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.
  */
-export function getWrappedDisplayName(WrappedComponent: React.ComponentClass, hocName: string) {
+export function getWrappedDisplayName(WrappedComponent: React.ComponentType, hocName: string) {
   const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
   return `${hocName}(${wrappedDisplayName})`;
 }
index 8a11dbe74288cf07376ea549bb44bd157f5b609f..0ea58a41e1885eb548d7c2cd076ad8fa3b5c8717 100644 (file)
@@ -23,7 +23,7 @@ import { getWrappedDisplayName } from './utils';
 import { Store, getCurrentUser } from '../../store/rootReducer';
 
 export function withCurrentUser<P>(
-  WrappedComponent: React.ComponentClass<P & { currentUser: T.CurrentUser }>
+  WrappedComponent: React.ComponentType<P & { currentUser: T.CurrentUser }>
 ) {
   class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> {
     static displayName = getWrappedDisplayName(WrappedComponent, 'withCurrentUser');
diff --git a/server/sonar-web/src/main/js/components/ui/NewInfoBox.css b/server/sonar-web/src/main/js/components/ui/NewInfoBox.css
new file mode 100644 (file)
index 0000000..0c8b0ca
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+.new-info-box {
+  display: flex;
+  padding: var(--gridSize);
+  background-color: var(--veryLightBlue);
+  border: 1px solid var(--alertBorderInfo);
+  border-radius: 2px;
+}
+
+.new-info-box-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
diff --git a/server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx b/server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx
new file mode 100644 (file)
index 0000000..c9a2da3
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { ButtonIcon } from './buttons';
+import ClearIcon from '../icons-components/ClearIcon';
+import { sonarcloudBlack500 } from '../../app/theme';
+import { translate } from '../../helpers/l10n';
+import './NewInfoBox.css';
+
+export interface Props {
+  children: React.ReactNode;
+  className?: string;
+  description: React.ReactNode;
+  onClose?: () => void;
+  title: string;
+}
+
+export default function NewInfoBox({ className, children, description, onClose, title }: Props) {
+  return (
+    <div className={classNames('new-info-box', className)} role="alert">
+      <div className="new-info-box-inner text-left">
+        <div className="new-info-box-header spacer-bottom">
+          <span className="display-inline-flex-center">
+            <span className="badge badge-new spacer-right">{translate('new')}</span>
+            <strong>{title}</strong>
+          </span>
+        </div>
+        <p className="note spacer-bottom">{description}</p>
+        {children}
+      </div>
+      <ButtonIcon className="button-small spacer-left" color={sonarcloudBlack500} onClick={onClose}>
+        <ClearIcon size={12} />
+      </ButtonIcon>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx
new file mode 100644 (file)
index 0000000..d7fc93c
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 NewInfoBox from '../NewInfoBox';
+import { click } from '../../../helpers/testUtils';
+
+it('should render correctly', () => {
+  expect(
+    shallow(
+      <NewInfoBox description="My description" onClose={jest.fn()} title="My title">
+        <div />
+      </NewInfoBox>
+    )
+  ).toMatchSnapshot();
+});
+
+it('should allow to opt out', () => {
+  const onClose = jest.fn();
+  const wrapper = shallow(
+    <NewInfoBox description="" onClose={onClose} title="">
+      <div />
+    </NewInfoBox>
+  );
+  click(wrapper.find('ButtonIcon'));
+  expect(onClose).toHaveBeenCalled();
+});
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap
new file mode 100644 (file)
index 0000000..fca669b
--- /dev/null
@@ -0,0 +1,44 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="new-info-box"
+  role="alert"
+>
+  <div
+    className="new-info-box-inner text-left"
+  >
+    <div
+      className="new-info-box-header spacer-bottom"
+    >
+      <span
+        className="display-inline-flex-center"
+      >
+        <span
+          className="badge badge-new spacer-right"
+        >
+          new
+        </span>
+        <strong>
+          My title
+        </strong>
+      </span>
+    </div>
+    <p
+      className="note spacer-bottom"
+    >
+      My description
+    </p>
+    <div />
+  </div>
+  <ButtonIcon
+    className="button-small spacer-left"
+    color="#8a8c8f"
+    onClick={[MockFunction]}
+  >
+    <ClearIcon
+      size={12}
+    />
+  </ButtonIcon>
+</div>
+`;
index 842ac795cb74b46f9c49b5576bee134e115d2f55..877de0ca6c891f944743184b825f8c0f2db605e1 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { isBitbucket, isGithub, isPersonal, isVSTS, sanitizeAlmId } from '../almIntegrations';
+import {
+  isBitbucket,
+  isGithub,
+  isPersonal,
+  isVSTS,
+  sanitizeAlmId,
+  getAlmMembersUrl
+} from '../almIntegrations';
+
+it('#getAlmMembersUrl', () => {
+  expect(
+    getAlmMembersUrl({ key: 'github', membersSync: true, url: 'https://github.com/Foo' })
+  ).toBe('https://github.com/orgs/Foo/people');
+  expect(
+    getAlmMembersUrl({ key: 'bitbucket', membersSync: true, url: 'https://bitbucket.com/Foo/' })
+  ).toBe('https://bitbucket.com/Foo/profile/members');
+});
 
 it('#isBitbucket', () => {
   expect(isBitbucket('bitbucket')).toBeTruthy();
index 650e0785581a03c97474a06ded2b8c3cf631bae9..ebeccc800f4d7af5407015a0e995707a62273251 100644 (file)
  */
 import { isLoggedIn } from './users';
 
+export function getAlmMembersUrl({ key, url }: T.OrganizationAlm): string {
+  if (!url.endsWith('/')) {
+    url += '/';
+  }
+  if (isGithub(key)) {
+    return url.replace('github.com/', 'github.com/orgs/') + 'people';
+  }
+  return url + 'profile/members';
+}
+
 export function hasAdvancedALMIntegration(user: T.CurrentUser) {
   return (
     isLoggedIn(user) && (isBitbucket(user.externalProvider) || isGithub(user.externalProvider))
index 4a7fd47182b0b64901860276b4d4852a43b995a1..9f7ec979805c2fd83f688871a300c79e1e86953a 100644 (file)
@@ -54,9 +54,13 @@ export function mockComponent(overrides: Partial<T.Component> = {}): T.Component
   };
 }
 
-export function mockCurrentUser(overrides: Partial<T.CurrentUser> = {}): T.CurrentUser {
+export function mockCurrentUser(overrides: Partial<T.LoggedInUser> = {}): T.LoggedInUser {
   return {
+    groups: [],
     isLoggedIn: true,
+    login: 'luke',
+    name: 'Skywalker',
+    scmAccounts: [],
     ...overrides
   };
 }
@@ -84,11 +88,24 @@ export function mockLocation(overrides: Partial<Location> = {}): Location {
 }
 
 export function mockOrganization(overrides: Partial<T.Organization> = {}): T.Organization {
-  return {
-    key: 'foo',
-    name: 'Foo',
+  return { key: 'foo', name: 'Foo', ...overrides };
+}
+
+export function mockOrganizationWithAdminActions(
+  overrides: Partial<T.Organization> = {},
+  actionsOverrides: Partial<T.Organization['actions']> = {}
+) {
+  return mockOrganization({ actions: { admin: true, ...actionsOverrides }, ...overrides });
+}
+
+export function mockOrganizationWithAlm(
+  overrides: Partial<T.Organization> = {},
+  almOverrides: Partial<T.Organization['alm']> = {}
+): T.Organization {
+  return mockOrganization({
+    alm: { key: 'github', membersSync: false, url: 'https://github.com/foo', ...almOverrides },
     ...overrides
-  };
+  });
 }
 
 export function mockQualityProfile(overrides: Partial<Profile> = {}): Profile {
index 8427e8ecd1059429f38ec9f0cd5a33f8ce4402fe..cbbb966a3a125a8021dca60ba6f9cc2dd056950b 100644 (file)
@@ -18,6 +18,7 @@ any=Any
 ascending=Ascending
 assignee=Assignee
 author=Author
+bitbucket=Bitbucket
 back=Back
 backup=Backup
 backup_verb=Back up
@@ -76,6 +77,7 @@ follow=Follow
 format=Format
 from=From
 global=Global
+github=GitHub
 help=Help
 hide=Hide
 inactive=Inactive
@@ -2673,6 +2675,25 @@ organization.members.manage_groups=Manage groups
 organization.members.members_groups={0}'s groups:
 organization.members.manage_a_team=Manage a team
 organization.members.add_to_members=Add to members
+organization.members.config_synchro=Configure Synchronization
+organization.members.auto_sync_with_x=Automatic sync with {0}
+organization.members.auto_sync_members_from_org.bitbucket=Members can be synchronized automatically from your Bitbucket team
+organization.members.auto_sync_members_from_org.github=Members can be synchronized automatically from your GitHub organization
+organization.members.auto_sync_total_help.bitbucket=You might not see all members from your Bitbucket team yet, as they need to reconnect to SonarCloud to be members of the organization.
+organization.members.auto_sync_total_help.github=You might not see all members from your GitHub organization yet, as they need to connect to SonarCloud at least once to appear in this list.
+organization.members.see_all_members_on_x=See all members on {0}
+organization.members.management.title=Members Management
+organization.members.management.description=Select your management mode for members of this organization
+organization.members.management.manual=Manual
+organization.members.management.manual.add_members_manually=Admin add members manually from Sonarcloud existing users
+organization.members.management.manual.choose_members_permissions=Admin chooses each member permissions
+organization.members.management.automatic=Automatic sync with {0}
+organization.members.management.automatic.synchronized_from.bitbucket=Members are synchronized automatically from your Bitbucket team
+organization.members.management.automatic.synchronized_from.github=Members are synchronized automatically from your GitHub organization
+organization.members.management.automatic.members_changes_reflected.bitbucket=Your team members must reconnect to SonarCloud to be automatically added to correct SonarCloud organization
+organization.members.management.automatic.members_changes_reflected.github=If you add or remove a member on GitHub, SonarCloud immediately reflect the changes
+organization.members.management.automatic.still_choose_members_permissions=Admin still manages permissions for each member in SonarCloud
+organization.members.management.automatic.warning=This will override your current Members and Permissions configuration
 organization.paid_plan.badge=Paid plan
 organization.default_visibility_of_new_projects=Default visibility of new projects:
 organization.change_visibility_form.header=Set Default Visibility of New Projects
@@ -2808,13 +2829,11 @@ onboarding.import_organization.org_not_found.tips_2=Try to uninstall and re-inst
 onboarding.import_organization.choose_organization=Choose an organization...
 onboarding.import_organization.choose_organization_button.bitbucket=Choose a team on Bitbucket
 onboarding.import_organization.choose_organization_button.github=Choose an organization on GitHub
-onboarding.import_organization.installing=Finalize installation of the ALM application...
-onboarding.import_organization.installing.bitbucket=Finalize installation of the Bitbucket application..
-onboarding.import_organization.installing.github=Finalize installation of the GitHub application...
+onboarding.import_organization.installing=Finalize installation of the {0} application...
 onboarding.import_organization.personal.page.header=Bind to your personal organization
 onboarding.import_organization.personal.import_org_details=Import personal organization details
 onboarding.import_organization.private.disabled=Selecting private repository is not available yet and will come soon. Meanwhile, you need to create the project manually.
-onboarding.import_organization.bitbucket=Import from BitBucket teams
+onboarding.import_organization.bitbucket=Import from Bitbucket teams
 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