]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-343 improve existing org alm binding
authorJeremy <47277647+jeremy-davis-sonarsource@users.noreply.github.com>
Mon, 4 Mar 2019 14:19:47 +0000 (15:19 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 6 Mar 2019 10:30:44 +0000 (11:30 +0100)
26 files changed:
server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg [new file with mode: 0644]
server/sonar-web/public/images/sonarcloud/github-unbound.svg [new file with mode: 0644]
server/sonar-web/src/main/js/app/styles/components/boxed-group.css
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/utils.ts
server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx
server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.tsx
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationEdit-test.tsx
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationEdit-test.tsx.snap
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx [deleted file]
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigation-test.tsx.snap
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationAdministration-test.tsx.snap
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap
server/sonar-web/src/main/js/components/nav/ContextNavBar.css
server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts
server/sonar-web/src/main/js/helpers/almIntegrations.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg b/server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg
new file mode 100644 (file)
index 0000000..c0bca9d
--- /dev/null
@@ -0,0 +1,12 @@
+<svg width="59" height="54" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <linearGradient x1="97.494%" y1="25.5%" x2="46.9%" y2="78.8%" id="a">
+      <stop stop-color="#B5B5B5" offset="0%"/>
+      <stop stop-color="#DDD" offset="100%"/>
+    </linearGradient>
+  </defs>
+  <g fill-rule="nonzero" fill="none">
+    <path d="M1.973.987A1.872 1.872 0 0 0 .102 3.159l7.947 48.253a2.547 2.547 0 0 0 2.49 2.125h38.133a1.872 1.872 0 0 0 1.872-1.573l7.949-48.8A1.872 1.872 0 0 0 56.621.992L1.973.987zm33.47 34.875H23.272l-3.3-17.217h18.42l-2.949 17.217z" fill="#DDD"/>
+    <path d="M55.965 18.644H38.392l-2.949 17.217H23.272L8.901 52.924a2.537 2.537 0 0 0 1.638.618h38.142a1.872 1.872 0 0 0 1.872-1.573l5.412-33.325z" fill="url(#a)"/>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/server/sonar-web/public/images/sonarcloud/github-unbound.svg b/server/sonar-web/public/images/sonarcloud/github-unbound.svg
new file mode 100644 (file)
index 0000000..1730782
--- /dev/null
@@ -0,0 +1,7 @@
+<svg width="14" height="14" xmlns="http://www.w3.org/2000/svg">
+  <g fill="none" fill-rule="evenodd">
+    <path d="M-1-1h16v16H-1z"/>
+    <path d="M13.061 3.574A7.06 7.06 0 0 0 10.514.962 6.719 6.719 0 0 0 7 0a6.72 6.72 0 0 0-3.514.962A7.059 7.059 0 0 0 .94 3.574 7.155 7.155 0 0 0 0 7.175c0 1.564.445 2.97 1.335 4.219.89 1.248 2.04 2.113 3.45 2.592.164.031.286.01.365-.065a.37.37 0 0 0 .118-.28l-.005-.505a85.532 85.532 0 0 1-.004-.831l-.21.037a2.61 2.61 0 0 1-.506.033 3.763 3.763 0 0 1-.633-.066 1.399 1.399 0 0 1-.61-.28 1.185 1.185 0 0 1-.402-.574l-.09-.215a2.346 2.346 0 0 0-.288-.477c-.13-.174-.263-.292-.396-.355l-.064-.047a.676.676 0 0 1-.119-.112.514.514 0 0 1-.082-.13c-.018-.044-.003-.08.046-.108.049-.028.137-.042.264-.042l.182.028c.122.025.272.1.452.224.179.125.326.287.442.486.14.255.308.45.505.584.198.134.397.2.597.2.2 0 .374-.015.52-.046.146-.031.282-.078.41-.14.055-.418.204-.738.447-.962a6.103 6.103 0 0 1-.935-.169 3.67 3.67 0 0 1-.856-.364 2.47 2.47 0 0 1-.734-.626c-.194-.25-.354-.576-.478-.981A4.774 4.774 0 0 1 2.534 6.8c0-.753.24-1.395.72-1.924-.225-.567-.204-1.202.064-1.906.176-.056.437-.014.783.126.347.14.6.26.761.36.162.1.29.183.388.252A6.324 6.324 0 0 1 7 3.466c.601 0 1.185.081 1.75.243l.346-.224c.237-.15.517-.287.839-.411.322-.125.568-.16.738-.103.274.704.298 1.339.073 1.906.48.53.72 1.17.72 1.924 0 .53-.062.998-.187 1.407-.124.408-.285.734-.483.98a2.563 2.563 0 0 1-.738.622c-.295.168-.58.29-.857.364a6.097 6.097 0 0 1-.934.169c.316.28.474.722.474 1.326v1.971c0 .112.038.206.114.28.076.075.196.097.36.066 1.41-.48 2.56-1.344 3.45-2.593C13.555 10.145 14 8.74 14 7.175a7.16 7.16 0 0 0-.939-3.601z" fill="#DDD" fill-rule="nonzero"/>
+  </g>
+</svg>
+
index 6912e432353a6bdffdea751c95f37525f905ad80..119c9a9cf400f39b1ac4ebffaaaf6930e2316179 100644 (file)
   padding: calc(2 * var(--gridSize)) 20px 0;
 }
 
+.boxed-group > h2.boxed-title {
+  padding: 0 0 8px;
+  margin: 0;
+  font-weight: 600;
+}
+
 .boxed-group hr {
   height: 0;
   border-top: 1px solid var(--gray94);
index 0e441863c12f6730a42ad6048e6136f2863b4450..4d28a6aed929b64e1912b70257d0bc1c54d4a1ea 100644 (file)
@@ -31,7 +31,9 @@ import {
   serializeQuery,
   Query,
   ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP,
-  Step
+  Step,
+  BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP,
+  BIND_ORGANIZATION_KEY
 } from './utils';
 import AlmApplicationInstalling from './AlmApplicationInstalling';
 import AutoOrganizationCreate from './AutoOrganizationCreate';
@@ -47,7 +49,8 @@ import {
   getAlmAppInfo,
   getAlmOrganization,
   GetAlmOrganizationResponse,
-  listUnboundApplications
+  listUnboundApplications,
+  bindAlmOrganization
 } from '../../../api/alm-integration';
 import { getSubscriptionPlans } from '../../../api/billing';
 import * as api from '../../../api/organizations';
@@ -62,6 +65,7 @@ import { get, remove } from '../../../helpers/storage';
 import { slugify } from '../../../helpers/strings';
 import { getOrganizationUrl } from '../../../helpers/urls';
 import { skipOnboarding } from '../../../store/users';
+import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage';
 import '../../tutorials/styles.css'; // TODO remove me
 
 interface Props {
@@ -82,6 +86,7 @@ interface State {
   almOrganization?: T.AlmOrganization;
   almOrgLoading: boolean;
   almUnboundApplications: T.AlmUnboundApplication[];
+  bindingExistingOrg: boolean;
   boundOrganization?: T.OrganizationBase;
   loading: boolean;
   organization?: T.Organization;
@@ -102,6 +107,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   state: State = {
     almOrgLoading: false,
     almUnboundApplications: [],
+    bindingExistingOrg: false,
     loading: true,
     step: Step.OrganizationDetails
   };
@@ -109,18 +115,29 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   componentDidMount() {
     this.mounted = true;
     addWhitePageClass();
-    const initRequests = [this.fetchSubscriptionPlans()];
-    if (hasAdvancedALMIntegration(this.props.currentUser)) {
-      initRequests.push(this.fetchAlmApplication());
 
-      const query = parseQuery(this.props.location.query);
-      if (query.almInstallId) {
-        this.fetchAlmOrganization(query.almInstallId);
-      } else {
-        initRequests.push(this.fetchAlmUnboundApplications());
+    const query = parseQuery(this.props.location.query);
+
+    //highjack the process for the organization settings
+    if (
+      hasAdvancedALMIntegration(this.props.currentUser) &&
+      query.almInstallId &&
+      this.isStoredTimestampValid(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP)
+    ) {
+      this.bindAndRedirectToOrganizationSettings(query.almInstallId);
+    } else {
+      const initRequests = [this.fetchSubscriptionPlans()];
+      if (hasAdvancedALMIntegration(this.props.currentUser)) {
+        initRequests.push(this.fetchAlmApplication());
+
+        if (query.almInstallId) {
+          this.fetchAlmOrganization(query.almInstallId);
+        } else {
+          initRequests.push(this.fetchAlmUnboundApplications());
+        }
       }
+      Promise.all(initRequests).then(this.stopLoading, this.stopLoading);
     }
-    Promise.all(initRequests).then(this.stopLoading, this.stopLoading);
   }
 
   componentDidUpdate(prevProps: WithRouterProps) {
@@ -249,6 +266,36 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     this.updateUrlState({ tab });
   };
 
+  bindAndRedirectToOrganizationSettings(installationId: string) {
+    const organizationKey = get(BIND_ORGANIZATION_KEY) || '';
+    remove(BIND_ORGANIZATION_KEY);
+
+    this.setState({ bindingExistingOrg: true });
+
+    bindAlmOrganization({
+      installationId,
+      organization: organizationKey
+    }).then(
+      () => {
+        this.props.router.push({
+          pathname: `/organizations/${organizationKey}`
+        });
+        addGlobalSuccessMessage(translate('organization.bind.success'));
+      },
+      () => {}
+    );
+  }
+
+  getHeader = (bindingExistingOrg: boolean, importPersonalOrg: boolean) => {
+    if (bindingExistingOrg) {
+      return translate('onboarding.binding_organization');
+    } else if (importPersonalOrg) {
+      return translate('onboarding.import_organization.personal.page.header');
+    } else {
+      return translate('onboarding.create_organization.page.header');
+    }
+  };
+
   setValidOrgKey = (almOrganization: T.AlmOrganization) => {
     const key = slugify(almOrganization.key);
     const keys = [key, ...times(9, i => `${key}-${i + 1}`)];
@@ -397,13 +444,12 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
       return <AlmApplicationInstalling almKey={query.almKey} />;
     }
 
-    const { almOrganization, subscriptionPlans } = this.state;
+    const { almOrganization, bindingExistingOrg, subscriptionPlans } = this.state;
     const importPersonalOrg = isPersonal(almOrganization)
       ? this.props.userOrganizations.find(o => o.key === currentUser.personalOrganization)
       : undefined;
-    const header = importPersonalOrg
-      ? translate('onboarding.import_organization.personal.page.header')
-      : translate('onboarding.create_organization.page.header');
+    const header = this.getHeader(bindingExistingOrg, !!importPersonalOrg);
+
     const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price;
 
     return (
index fc80778c40342662393e2beb62b165f3d56522e5..cdc8020444b962237a4a65cd3d9c6b6ce6b33edf 100644 (file)
 import * as React from 'react';
 import { times } from 'lodash';
 import { Location } from 'history';
-import { shallow } from 'enzyme';
+import { shallow, mount } from 'enzyme';
 import { CreateOrganization } from '../CreateOrganization';
 import {
+  bindAlmOrganization,
   getAlmAppInfo,
   getAlmOrganization,
   listUnboundApplications
@@ -35,7 +36,8 @@ import {
   mockOrganizationWithAdminActions,
   mockOrganizationWithAlm,
   mockAlmOrganization,
-  mockCurrentUser
+  mockCurrentUser,
+  mockLocation
 } from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
 
@@ -67,7 +69,8 @@ jest.mock('../../../../api/alm-integration', () => ({
       url: 'https://www.sonarsource.com'
     }
   }),
-  listUnboundApplications: jest.fn().mockResolvedValue([])
+  listUnboundApplications: jest.fn().mockResolvedValue([]),
+  bindAlmOrganization: jest.fn().mockResolvedValue({})
 }));
 
 jest.mock('../../../../api/organizations', () => ({
@@ -246,7 +249,9 @@ it('should display AutoOrganizationCreate with already bound organization', asyn
     almOrganization: { ...fooBarAlmOrganization, personal: false },
     boundOrganization
   });
-  (get as jest.Mock<any>).mockReturnValueOnce(Date.now().toString());
+  (get as jest.Mock<any>)
+    .mockReturnValueOnce(undefined) // For BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP
+    .mockReturnValueOnce(Date.now().toString()); // For ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP
   const push = jest.fn();
   const wrapper = shallowRender({
     currentUser: { ...user, externalProvider: 'github' },
@@ -294,18 +299,50 @@ it('should cancel imports', async () => {
   const wrapper = shallowRender({ router: mockRouter({ push }) });
   await waitAndUpdate(wrapper);
   wrapper.instance().handleCancelImport();
-  expect(push).toBeCalledWith({ query: {} });
+  expect(push).toBeCalledWith({ pathname: '/path', query: {}, state: {} });
 });
 
+it('should bind org and redirect to org home when coming from org binding', async () => {
+  const installation_id = '5328';
+  const orgKey = 'org4test';
+  const push = jest.fn();
+
+  (get as jest.Mock<any>)
+    .mockReturnValueOnce(Date.now().toString()) // For BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP
+    .mockReturnValueOnce(orgKey); // For BIND_ORGANIZATION_KEY
+
+  const wrapper = mountRender({
+    currentUser: mockCurrentUser({ ...user, externalProvider: 'github' }),
+    location: mockLocation({ query: { installation_id } }),
+    router: mockRouter({ push })
+  });
+  await waitAndUpdate(wrapper);
+
+  expect(bindAlmOrganization).toBeCalled();
+  expect(getAlmOrganization).not.toBeCalled();
+  expect(push).toBeCalledWith({
+    pathname: `/organizations/${orgKey}`
+  });
+});
+
+function mountRender(props: Partial<CreateOrganization['props']> = {}) {
+  return mount<CreateOrganization>(createComponent(props));
+}
+
 function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
-  return shallow<CreateOrganization>(
+  return shallow<CreateOrganization>(createComponent(props));
+}
+
+function createComponent(props: Partial<CreateOrganization['props']> = {}) {
+  return (
     <CreateOrganization
       createOrganization={jest.fn()}
       currentUser={user}
       deleteOrganization={jest.fn()}
-      // @ts-ignore avoid passing everything from WithRouterProps
-      location={{}}
+      location={mockLocation()}
+      params={{}}
       router={mockRouter()}
+      routes={[]}
       skipOnboarding={jest.fn()}
       updateOrganization={jest.fn()}
       userOrganizations={[
index b6d88841e2bfc414c02213654e970fe5c399654b..0d9485bba1a4d054b0f6debaeabb9d6d67cca1ae 100644 (file)
@@ -35,6 +35,10 @@ export const ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP =
 export const ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP =
   'sonarcloud.import_org.redirect_to_projects';
 
+export const BIND_ORGANIZATION_KEY = 'sonarcloud.bind_org.key';
+
+export const BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP = 'sonarcloud.bind_org.redirect_to_org';
+
 export enum Step {
   OrganizationDetails,
   Plan
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx
new file mode 100644 (file)
index 0000000..a463600
--- /dev/null
@@ -0,0 +1,126 @@
+/*
+ * 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 {
+  BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP,
+  BIND_ORGANIZATION_KEY
+} from '../../create/organization/utils';
+import IdentityProviderLink from '../../../components/ui/IdentityProviderLink';
+import { getAlmAppInfo } from '../../../api/alm-integration';
+import { sanitizeAlmId } from '../../../helpers/almIntegrations';
+import { translateWithParameters, translate } from '../../../helpers/l10n';
+import { save } from '../../../helpers/storage';
+import { getBaseUrl } from '../../../helpers/urls';
+
+interface Props {
+  currentUser: T.LoggedInUser;
+  organization: T.Organization;
+}
+
+interface State {
+  almApplication?: T.AlmApplication;
+}
+
+export default class OrganizationBind extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {};
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchAlmApplication();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchAlmApplication = () => {
+    return getAlmAppInfo().then(({ application }) => {
+      if (this.mounted) {
+        this.setState({ almApplication: application });
+      }
+    });
+  };
+
+  handleInstallAppClick = () => {
+    save(BIND_ORGANIZATION_KEY, this.props.organization.key);
+    save(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP, Date.now().toString());
+  };
+
+  render() {
+    const { currentUser, organization } = this.props;
+
+    const { almApplication } = this.state;
+
+    const almKey = sanitizeAlmId(currentUser.externalProvider || '');
+    const orgAlmKey = organization.alm ? sanitizeAlmId(organization.alm.key) : '';
+    return (
+      <div className="boxed-group boxed-group-inner">
+        <h2 className="boxed-title">
+          {translateWithParameters('organization.bind_to_x', translate(almKey))}
+        </h2>
+        {organization.alm ? (
+          <>
+            <span>{translate('organization.bound')}</span>
+            <a
+              className="link-no-underline big-spacer-left"
+              href={organization.alm.url}
+              rel="noopener noreferrer"
+              target="_blank">
+              <img
+                alt={translate(orgAlmKey)}
+                className="text-text-top little-spacer-right"
+                height={16}
+                src={`${getBaseUrl()}/images/sonarcloud/${orgAlmKey}.svg`}
+                width={16}
+              />
+              {translateWithParameters('organization.see_on_x', translate(orgAlmKey))}
+            </a>
+          </>
+        ) : (
+          <>
+            <p className="spacer-bottom">
+              {translateWithParameters('organization.binding_with_x_easy_sync', translate(almKey))}
+            </p>
+            <p className="big-spacer-bottom">
+              {translateWithParameters(
+                'organization.app_will_be_installed_on_x',
+                translate(almKey)
+              )}
+            </p>
+            {almApplication && (
+              <IdentityProviderLink
+                className="display-inline-block"
+                identityProvider={almApplication}
+                onClick={this.handleInstallAppClick}
+                small={true}
+                url={almApplication.installationUrl}>
+                {translate(
+                  'onboarding.import_organization.choose_the_organization_button',
+                  almApplication.key
+                )}
+              </IdentityProviderLink>
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+}
index 6954e3bf7e741665375c5a93b66a2c282196a01b..ab685c2044226331bd317b027c1933d15bdc3945 100644 (file)
@@ -18,7 +18,6 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import Helmet from 'react-helmet';
 import { connect } from 'react-redux';
 import ConfirmButton from '../../../components/controls/ConfirmButton';
 import InstanceMessage from '../../../components/common/InstanceMessage';
@@ -111,59 +110,53 @@ export class OrganizationDelete extends React.PureComponent<Props, State> {
 
   render() {
     const { hasPaidPlan } = this.state;
-    const title = translate('organization.delete');
     return (
-      <>
-        <Helmet title={title} />
-        <div className="page page-limited">
-          <header className="page-header">
-            <h1 className="page-title">{title}</h1>
-            <div className="page-description">
-              <InstanceMessage message={translate('organization.delete.description')} />
-            </div>
-          </header>
-          <ConfirmButton
-            confirmButtonText={translate('delete')}
-            confirmDisable={!this.isVerified()}
-            isDestructive={true}
-            modalBody={
-              <div>
-                {hasPaidPlan && (
-                  <Alert variant="warning">
-                    {translate('organization.delete.sonarcloud.paid_plan_info')}
-                  </Alert>
-                )}
-                <p>{translate('organization.delete.question')}</p>
-                <div className="spacer-top">
-                  <label htmlFor="downgrade-organization-name">
-                    {translate('billing.downgrade.modal.type_to_proceed')}
-                  </label>
-                  <div className="little-spacer-top">
-                    <input
-                      autoFocus={true}
-                      className="input-super-large"
-                      id="downgrade-organization-name"
-                      onChange={this.handleInput}
-                      type="text"
-                      value={this.state.verify}
-                    />
-                  </div>
+      <div className="boxed-group boxed-group-inner">
+        <h2 className="boxed-title">{translate('organization.delete')}</h2>
+        <p className="big-spacer-bottom width-50">
+          <InstanceMessage message={translate('organization.delete.description')} />
+        </p>
+        <ConfirmButton
+          confirmButtonText={translate('delete')}
+          confirmDisable={!this.isVerified()}
+          isDestructive={true}
+          modalBody={
+            <div>
+              {hasPaidPlan && (
+                <Alert variant="warning">
+                  {translate('organization.delete.sonarcloud.paid_plan_info')}
+                </Alert>
+              )}
+              <p>{translate('organization.delete.question')}</p>
+              <div className="spacer-top">
+                <label htmlFor="downgrade-organization-name">
+                  {translate('billing.downgrade.modal.type_to_proceed')}
+                </label>
+                <div className="little-spacer-top">
+                  <input
+                    autoFocus={true}
+                    className="input-super-large"
+                    id="downgrade-organization-name"
+                    onChange={this.handleInput}
+                    type="text"
+                    value={this.state.verify}
+                  />
                 </div>
               </div>
-            }
-            modalHeader={translateWithParameters(
-              'organization.delete_x',
-              this.props.organization.name
-            )}
-            onConfirm={this.onDelete}>
-            {({ onClick }) => (
-              <Button className="js-custom-measure-delete button-red" onClick={onClick}>
-                {translate('delete')}
-              </Button>
-            )}
-          </ConfirmButton>
-        </div>
-      </>
+            </div>
+          }
+          modalHeader={translateWithParameters(
+            'organization.delete_x',
+            this.props.organization.name
+          )}
+          onConfirm={this.onDelete}>
+          {({ onClick }) => (
+            <Button className="js-custom-measure-delete button-red" onClick={onClick}>
+              {translate('delete')}
+            </Button>
+          )}
+        </ConfirmButton>
+      </div>
     );
   }
 }
index 4d151adf2ab8291c9c18b8cdc8d7451c1dad8547..77942da7b550ee540fdc647838d6d6c77960a7f2 100644 (file)
@@ -21,9 +21,13 @@ import * as React from 'react';
 import Helmet from 'react-helmet';
 import { connect } from 'react-redux';
 import { debounce } from 'lodash';
+import OrganizationBind from './OrganizationBind';
+import OrganizationDelete from './OrganizationDelete';
+import { updateOrganization } from '../actions';
 import OrganizationAvatar from '../../../components/common/OrganizationAvatar';
+import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn';
 import { SubmitButton } from '../../../components/ui/buttons';
-import { updateOrganization } from '../actions';
+import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
 
 interface DispatchProps {
@@ -31,6 +35,7 @@ interface DispatchProps {
 }
 
 interface OwnProps {
+  currentUser: T.LoggedInUser;
   organization: T.Organization;
 }
 
@@ -100,7 +105,12 @@ export class OrganizationEdit extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const title = translate('organization.edit');
+    const { currentUser, organization } = this.props;
+    const title = translate('organization.settings');
+
+    const showBinding = hasAdvancedALMIntegration(currentUser);
+    const showDelete = organization.actions && organization.actions.delete;
+
     return (
       <div className="page page-limited">
         <Helmet title={title} />
@@ -110,6 +120,7 @@ export class OrganizationEdit extends React.PureComponent<Props, State> {
         </header>
 
         <div className="boxed-group boxed-group-inner">
+          <h2 className="boxed-title">{translate('organization.details')}</h2>
           <form onSubmit={this.handleSubmit}>
             <div className="form-field">
               <label htmlFor="organization-name">
@@ -198,6 +209,10 @@ export class OrganizationEdit extends React.PureComponent<Props, State> {
             {this.state.loading && <i className="spinner spacer-left" />}
           </form>
         </div>
+
+        {showBinding && <OrganizationBind currentUser={currentUser} organization={organization} />}
+
+        {showDelete && <OrganizationDelete organization={organization} />}
       </div>
     );
   }
@@ -208,4 +223,4 @@ const mapDispatchToProps = { updateOrganization: updateOrganization as any };
 export default connect(
   null,
   mapDispatchToProps
-)(OrganizationEdit);
+)(whenLoggedIn(OrganizationEdit));
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx
new file mode 100644 (file)
index 0000000..079de73
--- /dev/null
@@ -0,0 +1,72 @@
+/*
+ * 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 OrganizationBind from '../OrganizationBind';
+import {
+  BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP,
+  BIND_ORGANIZATION_KEY
+} from '../../../create/organization/utils';
+import { getAlmAppInfo } from '../../../../api/alm-integration';
+import { save } from '../../../../helpers/storage';
+import {
+  mockAlmApplication,
+  mockCurrentUser,
+  mockOrganization
+} from '../../../../helpers/testMocks';
+
+jest.mock('../../../../api/alm-integration', () => ({
+  getAlmAppInfo: jest.fn(() => Promise.resolve({ application: mockAlmApplication() }))
+}));
+jest.mock('../../../../helpers/storage', () => ({
+  save: jest.fn()
+}));
+
+jest.mock('../../../../helpers/system', () => ({ isSonarCloud: jest.fn() }));
+
+beforeEach(() => {
+  (getAlmAppInfo as jest.Mock<any>).mockClear();
+});
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should save state when handling Install App click', () => {
+  const orgKey = '56346';
+  shallowRender({ organization: mockOrganization({ key: orgKey }) })
+    .instance()
+    .handleInstallAppClick();
+
+  expect(save).toBeCalledTimes(2);
+  expect(save).nthCalledWith(1, BIND_ORGANIZATION_KEY, orgKey);
+  const secondCallArguments = (save as jest.Mock<any>).mock.calls[1];
+  expect(secondCallArguments[0]).toBe(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP);
+});
+
+function shallowRender(props: Partial<OrganizationBind['props']> = {}) {
+  return shallow<OrganizationBind>(
+    <OrganizationBind
+      currentUser={mockCurrentUser()}
+      organization={mockOrganization()}
+      {...props}
+    />
+  );
+}
index 1e320c7865b1ca719f22b7553da2d019a30d3999..1daa8265779a405cdc5dd9805e0405d99651b911 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import { OrganizationEdit } from '../OrganizationEdit';
+import { mockCurrentUser } from '../../../../helpers/testMocks';
 
 it('smoke test', () => {
   const organization = { key: 'foo', name: 'Foo' };
   const wrapper = shallow(
-    <OrganizationEdit organization={organization} updateOrganization={jest.fn()} />
+    <OrganizationEdit
+      currentUser={mockCurrentUser()}
+      organization={organization}
+      updateOrganization={jest.fn()}
+    />
   );
   expect(wrapper).toMatchSnapshot();
 
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap
new file mode 100644 (file)
index 0000000..6982163
--- /dev/null
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="boxed-group boxed-group-inner"
+>
+  <h2
+    className="boxed-title"
+  >
+    organization.bind_to_x.
+  </h2>
+  <p
+    className="spacer-bottom"
+  >
+    organization.binding_with_x_easy_sync.
+  </p>
+  <p
+    className="big-spacer-bottom"
+  >
+    organization.app_will_be_installed_on_x.
+  </p>
+</div>
+`;
index 43b66bfaea3f7183b1f7d1a177b6c38994492cbe..c2dee60e8f32f08c485bb551be457c91f8252d47 100644 (file)
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should show a info message for paying organization 1`] = `
-<Fragment>
-  <HelmetWrapper
-    defer={true}
-    encodeSpecialCharacters={true}
-    title="organization.delete"
-  />
-  <div
-    className="page page-limited"
+<div
+  className="boxed-group boxed-group-inner"
+>
+  <h2
+    className="boxed-title"
   >
-    <header
-      className="page-header"
-    >
-      <h1
-        className="page-title"
-      >
-        organization.delete
-      </h1>
-      <div
-        className="page-description"
-      >
-        <InstanceMessage
-          message="organization.delete.description"
-        />
-      </div>
-    </header>
-    <ConfirmButton
-      confirmButtonText="delete"
-      confirmDisable={true}
-      isDestructive={true}
-      modalBody={
-        <div>
-          <Alert
-            variant="warning"
+    organization.delete
+  </h2>
+  <p
+    className="big-spacer-bottom width-50"
+  >
+    <InstanceMessage
+      message="organization.delete.description"
+    />
+  </p>
+  <ConfirmButton
+    confirmButtonText="delete"
+    confirmDisable={true}
+    isDestructive={true}
+    modalBody={
+      <div>
+        <Alert
+          variant="warning"
+        >
+          organization.delete.sonarcloud.paid_plan_info
+        </Alert>
+        <p>
+          organization.delete.question
+        </p>
+        <div
+          className="spacer-top"
+        >
+          <label
+            htmlFor="downgrade-organization-name"
           >
-            organization.delete.sonarcloud.paid_plan_info
-          </Alert>
-          <p>
-            organization.delete.question
-          </p>
+            billing.downgrade.modal.type_to_proceed
+          </label>
           <div
-            className="spacer-top"
+            className="little-spacer-top"
           >
-            <label
-              htmlFor="downgrade-organization-name"
-            >
-              billing.downgrade.modal.type_to_proceed
-            </label>
-            <div
-              className="little-spacer-top"
-            >
-              <input
-                autoFocus={true}
-                className="input-super-large"
-                id="downgrade-organization-name"
-                onChange={[Function]}
-                type="text"
-                value=""
-              />
-            </div>
+            <input
+              autoFocus={true}
+              className="input-super-large"
+              id="downgrade-organization-name"
+              onChange={[Function]}
+              type="text"
+              value=""
+            />
           </div>
         </div>
-      }
-      modalHeader="organization.delete_x.Foo"
-      onConfirm={[Function]}
-    >
-      <Component />
-    </ConfirmButton>
-  </div>
-</Fragment>
+      </div>
+    }
+    modalHeader="organization.delete_x.Foo"
+    onConfirm={[Function]}
+  >
+    <Component />
+  </ConfirmButton>
+</div>
 `;
 
 exports[`smoke test 1`] = `
-<Fragment>
-  <HelmetWrapper
-    defer={true}
-    encodeSpecialCharacters={true}
-    title="organization.delete"
-  />
-  <div
-    className="page page-limited"
+<div
+  className="boxed-group boxed-group-inner"
+>
+  <h2
+    className="boxed-title"
   >
-    <header
-      className="page-header"
-    >
-      <h1
-        className="page-title"
-      >
-        organization.delete
-      </h1>
-      <div
-        className="page-description"
-      >
-        <InstanceMessage
-          message="organization.delete.description"
-        />
-      </div>
-    </header>
-    <ConfirmButton
-      confirmButtonText="delete"
-      confirmDisable={true}
-      isDestructive={true}
-      modalBody={
-        <div>
-          <p>
-            organization.delete.question
-          </p>
+    organization.delete
+  </h2>
+  <p
+    className="big-spacer-bottom width-50"
+  >
+    <InstanceMessage
+      message="organization.delete.description"
+    />
+  </p>
+  <ConfirmButton
+    confirmButtonText="delete"
+    confirmDisable={true}
+    isDestructive={true}
+    modalBody={
+      <div>
+        <p>
+          organization.delete.question
+        </p>
+        <div
+          className="spacer-top"
+        >
+          <label
+            htmlFor="downgrade-organization-name"
+          >
+            billing.downgrade.modal.type_to_proceed
+          </label>
           <div
-            className="spacer-top"
+            className="little-spacer-top"
           >
-            <label
-              htmlFor="downgrade-organization-name"
-            >
-              billing.downgrade.modal.type_to_proceed
-            </label>
-            <div
-              className="little-spacer-top"
-            >
-              <input
-                autoFocus={true}
-                className="input-super-large"
-                id="downgrade-organization-name"
-                onChange={[Function]}
-                type="text"
-                value=""
-              />
-            </div>
+            <input
+              autoFocus={true}
+              className="input-super-large"
+              id="downgrade-organization-name"
+              onChange={[Function]}
+              type="text"
+              value=""
+            />
           </div>
         </div>
-      }
-      modalHeader="organization.delete_x.Foo"
-      onConfirm={[Function]}
-    >
-      <Component />
-    </ConfirmButton>
-  </div>
-</Fragment>
+      </div>
+    }
+    modalHeader="organization.delete_x.Foo"
+    onConfirm={[Function]}
+  >
+    <Component />
+  </ConfirmButton>
+</div>
 `;
index e948f1810c5a3c4c916d57ea4ca2bd0022964cd8..b80d299eb1627a139a246b6eafea890d25635f4d 100644 (file)
@@ -7,7 +7,7 @@ exports[`smoke test 1`] = `
   <HelmetWrapper
     defer={true}
     encodeSpecialCharacters={true}
-    title="organization.edit"
+    title="organization.settings"
   />
   <header
     className="page-header"
@@ -15,12 +15,17 @@ exports[`smoke test 1`] = `
     <h1
       className="page-title"
     >
-      organization.edit
+      organization.settings
     </h1>
   </header>
   <div
     className="boxed-group boxed-group-inner"
   >
+    <h2
+      className="boxed-title"
+    >
+      organization.details
+    </h2>
     <form
       onSubmit={[Function]}
     >
@@ -162,7 +167,7 @@ exports[`smoke test 2`] = `
   <HelmetWrapper
     defer={true}
     encodeSpecialCharacters={true}
-    title="organization.edit"
+    title="organization.settings"
   />
   <header
     className="page-header"
@@ -170,12 +175,17 @@ exports[`smoke test 2`] = `
     <h1
       className="page-title"
     >
-      organization.edit
+      organization.settings
     </h1>
   </header>
   <div
     className="boxed-group boxed-group-inner"
   >
+    <h2
+      className="boxed-title"
+    >
+      organization.details
+    </h2>
     <form
       onSubmit={[Function]}
     >
@@ -317,7 +327,7 @@ exports[`smoke test 3`] = `
   <HelmetWrapper
     defer={true}
     encodeSpecialCharacters={true}
-    title="organization.edit"
+    title="organization.settings"
   />
   <header
     className="page-header"
@@ -325,12 +335,17 @@ exports[`smoke test 3`] = `
     <h1
       className="page-title"
     >
-      organization.edit
+      organization.settings
     </h1>
   </header>
   <div
     className="boxed-group boxed-group-inner"
   >
+    <h2
+      className="boxed-title"
+    >
+      organization.details
+    </h2>
     <form
       onSubmit={[Function]}
     >
index 3c7bbdb76a8a4ef0c2b12c4103d6b34c8acdaaf1..e9219a6bffdb95bcf974fb4a84405b9a429ac542 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import OrganizationNavigationHeaderContainer from './OrganizationNavigationHeaderContainer';
+import OrganizationNavigationHeader from './OrganizationNavigationHeader';
 import OrganizationNavigationMeta from './OrganizationNavigationMeta';
 import OrganizationNavigationMenuContainer from './OrganizationNavigationMenuContainer';
 import * as theme from '../../../app/theme';
@@ -40,7 +40,11 @@ export default function OrganizationNavigation({
   return (
     <ContextNavBar height={theme.contextNavHeightRaw} id="context-navigation">
       <div className="navbar-context-justified">
-        <OrganizationNavigationHeaderContainer organization={organization} />
+        <OrganizationNavigationHeader
+          currentUser={currentUser}
+          organization={organization}
+          organizations={userOrganizations}
+        />
         <OrganizationNavigationMeta
           currentUser={currentUser}
           organization={organization}
index 63228606f33d2127a778bf4d523a1b4317f48802..f1eb48f780798ac66fc6bdba334026e3637ce15a 100644 (file)
@@ -32,7 +32,6 @@ interface Props {
 const ADMIN_PATHS = [
   'edit',
   'groups',
-  'delete',
   'permissions',
   'permission_templates',
   'projects_management',
@@ -40,7 +39,7 @@ const ADMIN_PATHS = [
 ];
 
 export default function OrganizationNavigationAdministration({ location, organization }: Props) {
-  const { actions = {}, adminPages = [] } = organization;
+  const { adminPages = [] } = organization;
   const adminPathsWithExtensions = adminPages.map(e => `extension/${e.key}`).concat(ADMIN_PATHS);
   const adminActive = adminPathsWithExtensions.some(path =>
     location.pathname.endsWith(`organizations/${organization.key}/${path}`)
@@ -50,6 +49,11 @@ export default function OrganizationNavigationAdministration({ location, organiz
     <Dropdown
       overlay={
         <ul className="menu">
+          <li>
+            <Link activeClassName="active" to={`/organizations/${organization.key}/edit`}>
+              {translate('organization.settings')}
+            </Link>
+          </li>
           {adminPages.map(extension => (
             <li key={extension.key}>
               <Link
@@ -88,18 +92,6 @@ export default function OrganizationNavigationAdministration({ location, organiz
               {translate('webhooks.page')}
             </Link>
           </li>
-          <li>
-            <Link activeClassName="active" to={`/organizations/${organization.key}/edit`}>
-              {translate('edit')}
-            </Link>
-          </li>
-          {actions.delete && (
-            <li>
-              <Link activeClassName="active" to={`/organizations/${organization.key}/delete`}>
-                {translate('delete')}
-              </Link>
-            </li>
-          )}
         </ul>
       }
       tagName="li">
index 2f7426880c48b3ec812c72c97727a2c9fe18ec8c..28b8d1cb6d9b7848a85f1debce02864ac56eb9c3 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { Link } from 'react-router';
 import { sortBy } from 'lodash';
 import OrganizationAvatar from '../../../components/common/OrganizationAvatar';
 import Dropdown from '../../../components/controls/Dropdown';
+import Tooltip from '../../../components/controls/Tooltip';
 import DropdownIcon from '../../../components/icons-components/DropdownIcon';
 import OrganizationListItem from '../../../components/ui/OrganizationListItem';
-import { sanitizeAlmId } from '../../../helpers/almIntegrations';
+import {
+  sanitizeAlmId,
+  hasAdvancedALMIntegration,
+  getUserAlmKey
+} from '../../../helpers/almIntegrations';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/urls';
 
-interface Props {
+export interface Props {
+  currentUser: T.CurrentUser;
   organization: T.Organization;
   organizations: T.Organization[];
 }
 
-export default function OrganizationNavigationHeader({ organization, organizations }: Props) {
+export default function OrganizationNavigationHeader({
+  currentUser,
+  organization,
+  organizations
+}: Props) {
   const other = organizations.filter(o => o.key !== organization.key);
 
+  let almKey;
+  let tooltipContent;
+  let tooltipIconSrc;
+  if (organization.alm) {
+    almKey = sanitizeAlmId(organization.alm.key);
+    tooltipContent = (
+      <>
+        <p>{translateWithParameters('organization.bound_to_x', translate(almKey))}</p>
+        <hr className="spacer-top spacer-bottom" />
+        <a href={organization.alm.url} rel="noopener noreferrer" target="_blank">
+          {translateWithParameters('organization.see_on_x', translate(almKey))}
+        </a>
+      </>
+    );
+    tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}.svg`;
+  } else if (hasAdvancedALMIntegration(currentUser)) {
+    almKey = getUserAlmKey(currentUser) || '';
+    tooltipContent = (
+      <>
+        <p>{translateWithParameters('organization.not_bound_to_x', translate(almKey))}</p>
+        <hr className="spacer-top spacer-bottom" />
+        <Link to={`/organizations/${organization.key}/edit`}>
+          {translate('organization.go_to_settings_to_bind')}
+        </Link>
+      </>
+    );
+    tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}-unbound.svg`;
+  }
+
   return (
     <header className="navbar-context-header">
       <OrganizationAvatar organization={organization} />
@@ -57,20 +98,16 @@ export default function OrganizationNavigationHeader({ organization, organizatio
       ) : (
         <span className="spacer-left">{organization.name}</span>
       )}
-      {organization.alm && (
-        <a
-          className="link-no-underline"
-          href={organization.alm.url}
-          rel="noopener noreferrer"
-          target="_blank">
+      {almKey && (
+        <Tooltip mouseLeaveDelay={0.25} overlay={tooltipContent}>
           <img
-            alt={sanitizeAlmId(organization.alm.key)}
-            className="text-text-top spacer-left"
+            alt={translate(almKey)}
+            className="text-middle spacer-left"
             height={16}
-            src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(organization.alm.key)}.svg`}
+            src={tooltipIconSrc}
             width={16}
           />
-        </a>
+        </Tooltip>
       )}
       {organization.description != null && (
         <div className="navbar-context-description">
diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx
deleted file mode 100644 (file)
index 999b68b..0000000
+++ /dev/null
@@ -1,32 +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 { connect } from 'react-redux';
-import OrganizationNavigationHeader from './OrganizationNavigationHeader';
-import { getMyOrganizations, Store } from '../../../store/rootReducer';
-
-interface StateProps {
-  organizations: T.Organization[];
-}
-
-const mapStateToProps = (state: Store): StateProps => ({
-  organizations: getMyOrganizations(state)
-});
-
-export default connect(mapStateToProps)(OrganizationNavigationHeader);
index 39519ffd93927d12c513376e9580990c392d0acd..12f1d27fb4cd71d619ffce349ce8424f63a883dd 100644 (file)
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import OrganizationNavigationHeader from '../OrganizationNavigationHeader';
-import { mockOrganizationWithAlm } from '../../../../helpers/testMocks';
+import OrganizationNavigationHeader, { Props } from '../OrganizationNavigationHeader';
+import { mockOrganizationWithAlm, mockCurrentUser } from '../../../../helpers/testMocks';
 
 it('renders', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('renders with alm integration', () => {
   expect(
-    shallow(
-      <OrganizationNavigationHeader
-        organization={{ key: 'foo', name: 'Foo', projectVisibility: 'public' }}
-        organizations={[]}
-      />
-    )
+    shallowRender({ organization: mockOrganizationWithAlm({ projectVisibility: 'public' }) })
   ).toMatchSnapshot();
 });
 
-it('renders with alm integration', () => {
+it('renders for external user w/o alm integration', () => {
   expect(
-    shallow(
-      <OrganizationNavigationHeader
-        organization={mockOrganizationWithAlm({ projectVisibility: 'public' })}
-        organizations={[]}
-      />
-    )
+    shallowRender({ currentUser: mockCurrentUser({ externalProvider: 'github' }) })
   ).toMatchSnapshot();
 });
 
@@ -49,15 +43,23 @@ it('renders dropdown', () => {
     { actions: { admin: true }, key: 'org1', name: 'org1', projectVisibility: 'public' },
     { actions: { admin: false }, key: 'org2', name: 'org2', projectVisibility: 'public' }
   ];
-  const wrapper = shallow(
+  const wrapper = shallowRender({
+    organizations
+  });
+  expect(wrapper.find('Dropdown')).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Props> = {}) {
+  return shallow(
     <OrganizationNavigationHeader
+      currentUser={mockCurrentUser()}
       organization={{
         key: 'foo',
         name: 'Foo',
         projectVisibility: 'public'
       }}
-      organizations={organizations}
+      organizations={[]}
+      {...props}
     />
   );
-  expect(wrapper.find('Dropdown')).toMatchSnapshot();
-});
+}
index 0da0558483a264bcf40352c1f36a475b5bf19897..1db61a2ee48fdc7ef7f81116a8f54f9dc946c67a 100644 (file)
@@ -8,7 +8,12 @@ exports[`render 1`] = `
   <div
     className="navbar-context-justified"
   >
-    <Connect(OrganizationNavigationHeader)
+    <OrganizationNavigationHeader
+      currentUser={
+        Object {
+          "isLoggedIn": false,
+        }
+      }
       organization={
         Object {
           "key": "foo",
@@ -16,6 +21,7 @@ exports[`render 1`] = `
           "projectVisibility": "public",
         }
       }
+      organizations={Array []}
     />
     <OrganizationNavigationMeta
       currentUser={
index 27d6f917f784360770e85494d373dfc7028171fe..da8dbbc2c8780d674deba30874a4b8a506ceab90 100644 (file)
@@ -6,6 +6,16 @@ exports[`renders 1`] = `
     <ul
       className="menu"
     >
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to="/organizations/foo/edit"
+        >
+          organization.settings
+        </Link>
+      </li>
       <li>
         <Link
           activeClassName="active"
@@ -56,16 +66,6 @@ exports[`renders 1`] = `
           webhooks.page
         </Link>
       </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to="/organizations/foo/edit"
-        >
-          edit
-        </Link>
-      </li>
     </ul>
   }
   tagName="li"
index c02c0831a7f8a751c65299d1642eb90dbedc0eee..c580daad4e27aabee12fc844270768720f5118be 100644 (file)
@@ -67,6 +67,55 @@ exports[`renders dropdown 1`] = `
 </Dropdown>
 `;
 
+exports[`renders for external user w/o alm integration 1`] = `
+<header
+  className="navbar-context-header"
+>
+  <OrganizationAvatar
+    organization={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+        "projectVisibility": "public",
+      }
+    }
+  />
+  <span
+    className="spacer-left"
+  >
+    Foo
+  </span>
+  <Tooltip
+    mouseLeaveDelay={0.25}
+    overlay={
+      <React.Fragment>
+        <p>
+          organization.not_bound_to_x.github
+        </p>
+        <hr
+          className="spacer-top spacer-bottom"
+        />
+        <Link
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to="/organizations/foo/edit"
+        >
+          organization.go_to_settings_to_bind
+        </Link>
+      </React.Fragment>
+    }
+  >
+    <img
+      alt="github"
+      className="text-middle spacer-left"
+      height={16}
+      src="/images/sonarcloud/github-unbound.svg"
+      width={16}
+    />
+  </Tooltip>
+</header>
+`;
+
 exports[`renders with alm integration 1`] = `
 <header
   className="navbar-context-header"
@@ -90,19 +139,33 @@ exports[`renders with alm integration 1`] = `
   >
     Foo
   </span>
-  <a
-    className="link-no-underline"
-    href="https://github.com/foo"
-    rel="noopener noreferrer"
-    target="_blank"
+  <Tooltip
+    mouseLeaveDelay={0.25}
+    overlay={
+      <React.Fragment>
+        <p>
+          organization.bound_to_x.github
+        </p>
+        <hr
+          className="spacer-top spacer-bottom"
+        />
+        <a
+          href="https://github.com/foo"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
+          organization.see_on_x.github
+        </a>
+      </React.Fragment>
+    }
   >
     <img
       alt="github"
-      className="text-text-top spacer-left"
+      className="text-middle spacer-left"
       height={16}
       src="/images/sonarcloud/github.svg"
       width={16}
     />
-  </a>
+  </Tooltip>
 </header>
 `;
index 871963945d7fef9adfeaad58f8488ddd101744cf..fa4a52d5b9663086ca89eb5df65e3dbf88acfca6 100644 (file)
@@ -93,9 +93,8 @@
 .navbar-context-description {
   display: inline-block;
   line-height: var(--controlHeight);
-  margin-left: 16px;
+  margin-left: var(--gridSize);
   padding-top: 4px;
-  padding-left: 4px;
   color: var(--secondFontColor);
   font-size: var(--smallFontSize);
 }
index b2a09af3ab0f3eda0dea27538c9a595637e47892..de98abb49683e7365ae1d7017c0fc9ff9425835e 100644 (file)
@@ -23,8 +23,10 @@ import {
   isPersonal,
   isVSTS,
   sanitizeAlmId,
-  getAlmMembersUrl
+  getAlmMembersUrl,
+  getUserAlmKey
 } from '../almIntegrations';
+import { mockCurrentUser } from '../testMocks';
 
 it('#getAlmMembersUrl', () => {
   expect(getAlmMembersUrl('github', 'https://github.com/Foo')).toBe(
@@ -69,3 +71,16 @@ it('#sanitizeAlmId', () => {
   expect(sanitizeAlmId('bitbucket')).toBe('bitbucket');
   expect(sanitizeAlmId('github')).toBe('github');
 });
+
+describe('getUserAlmKey', () => {
+  it('should return sanitized almKey', () => {
+    expect(getUserAlmKey(mockCurrentUser({ externalProvider: 'bitbucketcloud' }))).toBe(
+      'bitbucket'
+    );
+  });
+
+  it('should return undefined', () => {
+    expect(getUserAlmKey(mockCurrentUser())).toBeUndefined();
+    expect(getUserAlmKey(mockCurrentUser({ isLoggedIn: undefined }))).toBeUndefined();
+  });
+});
index c3cfc0744a35887ce4c6d0124a5d70a3a1ed4e4d..dfe28e3dd53d434f1851b5f9cf6352a13cd8f620 100644 (file)
@@ -29,6 +29,12 @@ export function getAlmMembersUrl(key: string, url: string): string {
   return url + 'profile/members';
 }
 
+export function getUserAlmKey(user: T.CurrentUser) {
+  return isLoggedIn(user) && user.externalProvider
+    ? sanitizeAlmId(user.externalProvider)
+    : undefined;
+}
+
 export function hasAdvancedALMIntegration(user: T.CurrentUser) {
   return (
     isLoggedIn(user) && (isBitbucket(user.externalProvider) || isGithub(user.externalProvider))
index 17ffc9ce1300b6ad8370871935cbb57130bce297..9ba97fab7d9b5f6bfd007a0e47053eabcdfdddcd 100644 (file)
@@ -2647,6 +2647,11 @@ about_page.scanners.ant=SonarQube Scanner for Ant
 organization.avatar=Avatar
 organization.avatar.description=Url of a small image that represents the organization (preferably 30px height).
 organization.avatar.preview=Preview
+organization.bind_to_x=Bind this organization to {0}
+organization.go_to_settings_to_bind=Go to Organization Settings to bind it.
+organization.bound=This organization is bound.
+organization.bound_to_x=This organization is bound to {0}
+organization.not_bound_to_x=This organization is not bound to {0}
 organization.created=Organization "{0}" has been created.
 organization.delete=Delete Organization
 organization.delete_x=Delete the "{0}" organization
@@ -2657,14 +2662,18 @@ organization.deleted=Organization has been deleted.
 organization.deleted_x=Organization "{0}" has been deleted.
 organization.description=Description
 organization.description.description=Description of the organization.
-organization.edit=Edit Organization
+organization.details=Organization details
 organization.key=Key
 organization.key.description=Key of the organization (up to 255 characters). All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). When not specified, the key is computed from the name.
 organization.name=Name
 organization.name.description=Name of the organization (up to 255 characters).
+organization.see_on_x=See on {0}
+organization.settings=Organization settings
 organization.updated=Organization details have been updated.
 organization.url=Url
 organization.url.description=Url of the homepage of the organization.
+organization.binding_with_x_easy_sync=Binding an organization from SonarCloud with {0} is an easy way to keep them synchronized.
+organization.app_will_be_installed_on_x=To bind this organization to {0}, the SonarCloud application will be installed. 
 organization.members.page=Members
 organization.members.page.description=Add users to the organization and grant them permissions to work on the projects. See {link} documentation.
 organization.members.add=Add a member
@@ -2695,7 +2704,7 @@ 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
 organization.change_visibility_form.warning=This will not change the visibility of already existing projects.organization.change_visibility_form.submit=Change Default Visibility
-
+organization.bind.success=Organization bound successfully
 
 #------------------------------------------------------------------------------
 #
@@ -2827,6 +2836,8 @@ 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.choose_the_organization_button.bitbucket=Choose the team on Bitbucket
+onboarding.import_organization.choose_the_organization_button.github=Choose the organization on GitHub
 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
@@ -2839,7 +2850,7 @@ onboarding.import_organization.members_sync_info_x=All members from your {0} {1}
 onboarding.import_organization.bind_members_not_sync_info_x=We'll keep your members, groups and permissions as they are today on SonarCloud. To sync your members with your {0}, enable members sync in your Members tab.
 onboarding.import_organization_x=Import {avatar} {name} into a SonarCloud organization
 onboarding.import_personal_organization_x=Bind {avatar} {name} with your personal SonarCloud organization {personalAvatar} {personalName}
-
+onboarding.binding_organization=Binding organization
 
 onboarding.team.header=Join a team
 onboarding.team.first_step=Well congrats, the first step is done!