]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11321 Create organization from GitHub organization or BitBucket team
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Mon, 15 Oct 2018 09:55:35 +0000 (11:55 +0200)
committerSonarTech <sonartech@sonarsource.com>
Fri, 16 Nov 2018 19:21:03 +0000 (20:21 +0100)
* Create api/alm_integration/show_organization and handle only GitHub installation
* Add import from ALM tab in Create Org page
* Do not show error while validating detail input
* Add step to create organization from ALM
* Display a warning if the installation id was not found
* Add Alm link to remote organization in org context
* Create GET api/alm_integration/show_app_info

78 files changed:
server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java
server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java
server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
server/sonar-web/src/main/js/api/alm-integration.ts
server/sonar-web/src/main/js/api/organizations.ts
server/sonar-web/src/main/js/app/components/Landing.tsx
server/sonar-web/src/main/js/app/components/StartupModal.tsx
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx
server/sonar-web/src/main/js/app/styles/components/menu.css
server/sonar-web/src/main/js/app/styles/init/forms.css
server/sonar-web/src/main/js/app/styles/init/icons.css
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/app/styles/sonarcloud.css
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx
server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx
server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx
server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx
server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx
server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-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__/ManualOrganizationCreate-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/utils.ts
server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx
server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx
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/apps/overview/components/SonarCloudEmptyOverview.tsx
server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx
server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx
server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx
server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx
server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx
server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx
server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx
server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx
server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx
server/sonar-web/src/main/js/components/controls/Tabs.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/Tabs.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
server/sonar-web/src/main/js/helpers/almIntegrations.ts
server/sonar-web/src/main/js/helpers/organizations.ts
server/sonar-web/src/main/js/helpers/users.ts [new file with mode: 0644]
server/sonar-web/src/main/js/store/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 03361e5b586bba901ed71dc00b8c4c83b68a28f2..04302fe6f06832448b61ff0590fdc8626258f519 100644 (file)
@@ -52,6 +52,11 @@ public class AlmAppInstallDao implements Dao {
     return Optional.ofNullable(mapper.selectByOwner(alm.getId(), ownerId));
   }
 
+  public Optional<String> getOwerId(DbSession dbSession, ALM alm, String installationId) {
+    AlmAppInstallMapper mapper = getMapper(dbSession);
+    return Optional.ofNullable(mapper.selectOwnerId(alm.getId(), installationId));
+  }
+
   public List<AlmAppInstallDto> findAllWithNoOwnerType(DbSession dbSession) {
     return getMapper(dbSession).selectAllWithNoOwnerType();
   }
index 809a054ca26bb2a630bcf859ce0a394820e5f71b..cb979c2217fe1e245efa293514d18894dc59a2ed 100644 (file)
@@ -29,6 +29,9 @@ public interface AlmAppInstallMapper {
   @CheckForNull
   AlmAppInstallDto selectByOwner(@Param("almId") String almId, @Param("ownerId") String ownerId);
 
+  @CheckForNull
+  String selectOwnerId(@Param("almId") String almId, @Param("installId") String installId);
+
   List<AlmAppInstallDto> selectAllWithNoOwnerType();
 
   void insert(@Param("uuid") String uuid, @Param("almId") String almId, @Param("ownerId") String ownerId,
index 2454efde08c01ba7212e25dbd4b79518f673e54d..d96a33dfe13335a7b92a4d25a5b2f570c5b5a2d1 100644 (file)
       and owner_id = #{ownerId, jdbcType=VARCHAR}
   </select>
 
+  <select id="selectOwnerId" parameterType="Map" resultType="String">
+    select
+    owner_id as ownerId
+    from
+    alm_app_installs
+    where
+    alm_id = #{almId, jdbcType=VARCHAR}
+    and install_id = #{installId, jdbcType=VARCHAR}
+  </select>
+
   <select id="selectAllWithNoOwnerType" parameterType="Map" resultType="org.sonar.db.alm.AlmAppInstallDto">
     select <include refid="sqlColumns" />
     from
index 5a15a5a04abc3f28324d7c1c0b59f1944c9697db..00955915f03eb0263b82b5e4938013aefcc908c2 100644 (file)
@@ -72,7 +72,6 @@ public class AlmAppInstallDaoTest {
     assertThat(underTest.selectByOwner(dbSession, BITBUCKETCLOUD, A_OWNER)).isEmpty();
   }
 
-
   @Test
   public void selectByOwner_throws_NPE_when_alm_is_null() {
     expectAlmNPE();
@@ -94,6 +93,16 @@ public class AlmAppInstallDaoTest {
     underTest.selectByOwner(dbSession, GITHUB, EMPTY_STRING);
   }
 
+  @Test
+  public void getOwnerId() {
+    when(uuidFactory.create()).thenReturn(A_UUID);
+    underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, AN_INSTALL);
+
+    assertThat(underTest.getOwerId(dbSession, GITHUB, AN_INSTALL)).contains(A_OWNER);
+    assertThat(underTest.getOwerId(dbSession, GITHUB, "unknown")).isEmpty();
+    assertThat(underTest.getOwerId(dbSession, BITBUCKETCLOUD, AN_INSTALL)).isEmpty();
+  }
+
   @Test
   public void insert_throws_NPE_if_alm_is_null() {
     expectAlmNPE();
@@ -170,7 +179,7 @@ public class AlmAppInstallDaoTest {
     underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, AN_INSTALL);
 
     when(system2.now()).thenReturn(DATE_LATER);
-    underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, OTHER_INSTALL);
+    underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER,true,  OTHER_INSTALL);
 
     assertThatAlmAppInstall(GITHUB, A_OWNER)
       .hasInstallId(OTHER_INSTALL)
index 190bdecd3ed71a4c1544c79380b387c73827a7be..8a51b94e839132bdfd0e4db632448c8caa89accf 100644 (file)
@@ -38,6 +38,7 @@ import org.sonar.server.exceptions.UnauthorizedException;
 
 import static java.lang.String.format;
 import static org.apache.commons.lang.StringUtils.defaultString;
+import static org.sonar.server.user.UserSession.IdentityProvider.SONARQUBE;
 
 public abstract class AbstractUserSession implements UserSession {
   private static final Set<String> PUBLIC_PERMISSIONS = ImmutableSet.of(UserRole.USER, UserRole.CODEVIEWER);
@@ -45,26 +46,15 @@ public abstract class AbstractUserSession implements UserSession {
   private static final String AUTHENTICATION_IS_REQUIRED_MESSAGE = "Authentication is required";
 
   protected static Identity computeIdentity(UserDto userDto) {
-    switch (userDto.getExternalIdentityProvider()) {
-      case "github":
-        return new Identity(IdentityProvider.GITHUB, externalIdentityOf(userDto));
-      case "bitbucket":
-        return new Identity(IdentityProvider.BITBUCKET, externalIdentityOf(userDto));
-      case "sonarqube":
-        return new Identity(IdentityProvider.SONARQUBE, null);
-      default:
-        return new Identity(IdentityProvider.OTHER, externalIdentityOf(userDto));
-    }
+    IdentityProvider identityProvider = IdentityProvider.getFromKey(userDto.getExternalIdentityProvider());
+    ExternalIdentity externalIdentity = identityProvider == SONARQUBE ? null : externalIdentityOf(userDto);
+    return new Identity(identityProvider, externalIdentity);
   }
 
-  @CheckForNull
   private static ExternalIdentity externalIdentityOf(UserDto userDto) {
     String externalId = userDto.getExternalId();
     String externalLogin = userDto.getExternalLogin();
-    if (externalId == null && externalLogin == null) {
-      return null;
-    }
-    return new ExternalIdentity(externalId == null ? externalLogin : externalId, externalLogin);
+    return new ExternalIdentity(externalId, externalLogin);
   }
 
   protected static final class Identity {
index 7252ea912e16239ca7f54acc861db4c2b532675b..9f898109d57276a5728b266fed90a541541edb7e 100644 (file)
@@ -19,6 +19,7 @@
  */
 package org.sonar.server.user;
 
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
@@ -72,7 +73,24 @@ public interface UserSession {
    * This enum supports by name only the few providers for which specific code exists.
    */
   enum IdentityProvider {
-    SONARQUBE, GITHUB, BITBUCKET, OTHER
+    SONARQUBE("sonarqube"), GITHUB("github"), BITBUCKET("bitbucket"), OTHER("other");
+
+    String key;
+
+    IdentityProvider(String key) {
+      this.key = key;
+    }
+
+    public String getKey() {
+      return key;
+    }
+
+    public static IdentityProvider getFromKey(String key) {
+      return Arrays.stream(IdentityProvider.values())
+        .filter(i -> i.getKey().equals(key))
+        .findAny()
+        .orElse(OTHER);
+    }
   }
 
   /**
index d35ff9995e0e0dcc39dd04832683bf8751487e23..c3f560044f236d9e05493d10ab2fc9adcb4f7fb9 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { getJSON, postJSON } from '../helpers/request';
-import { AlmRepository } from '../app/types';
+import { AlmRepository, AlmApplication, AlmOrganization } from '../app/types';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
+export function getAlmAppInfo(): Promise<{ application: AlmApplication }> {
+  return getJSON('/api/alm_integration/show_app_info').catch(throwGlobalError);
+}
+
+export function getAlmOrganization(data: { installationId: string }): Promise<AlmOrganization> {
+  return getJSON('/api/alm_integration/show_organization', data).then(
+    ({ organization }) => ({
+      ...organization,
+      name: organization.name || organization.key
+    }),
+    throwGlobalError
+  );
+}
+
 export function getRepositories(): Promise<{
   almIntegration: {
     installed: boolean;
index 289d6ce99d54b2a97965b9d81f542cbe7cac876f..1b72037ce832fb01edb9b1bd79c6036c9e543178 100644 (file)
@@ -39,12 +39,12 @@ export function getOrganization(key: string): Promise<Organization | undefined>
 }
 
 interface GetOrganizationNavigation {
+  adminPages: Array<{ key: string; name: string }>;
   canAdmin: boolean;
   canDelete: boolean;
   canProvisionProjects: boolean;
   isDefault: boolean;
   pages: Array<{ key: string; name: string }>;
-  adminPages: Array<{ key: string; name: string }>;
 }
 
 export function getOrganizationNavigation(key: string): Promise<GetOrganizationNavigation> {
@@ -54,7 +54,9 @@ export function getOrganizationNavigation(key: string): Promise<GetOrganizationN
   );
 }
 
-export function createOrganization(data: OrganizationBase): Promise<Organization> {
+export function createOrganization(
+  data: OrganizationBase & { installId?: string }
+): Promise<Organization> {
   return postJSON('/api/organizations/create', data).then(r => r.organization, throwGlobalError);
 }
 
index f96be5fc011c697d4a8b8bb4c065a8adf9971f21..421c6cceb7c6615132a10044c86b0466d67444ac 100644 (file)
@@ -21,9 +21,10 @@ import * as React from 'react';
 import * as PropTypes from 'prop-types';
 import { connect } from 'react-redux';
 import { Location } from 'history';
-import { CurrentUser, isLoggedIn } from '../types';
+import { CurrentUser } from '../types';
 import { getCurrentUser, Store } from '../../store/rootReducer';
 import { getHomePageUrl } from '../../helpers/urls';
+import { isLoggedIn } from '../../helpers/users';
 
 interface StateProps {
   currentUser: CurrentUser | undefined;
index a5f0263139aed28f49dc850b4943e28a211c97e5..c0393198c15717e740f801b245f5b4ac7038536e 100644 (file)
@@ -21,7 +21,7 @@ import * as React from 'react';
 import * as PropTypes from 'prop-types';
 import { connect } from 'react-redux';
 import { withRouter, WithRouterProps } from 'react-router';
-import { CurrentUser, isLoggedIn } from '../types';
+import { CurrentUser } from '../types';
 import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates';
 import { EditionKey } from '../../apps/marketplace/utils';
 import { getCurrentUser, getAppState, Store } from '../../store/rootReducer';
@@ -32,6 +32,7 @@ import { save, get } from '../../helpers/storage';
 import { isSonarCloud } from '../../helpers/system';
 import { skipOnboarding } from '../../api/users';
 import { lazyLoad } from '../../components/lazyLoad';
+import { isLoggedIn } from '../../helpers/users';
 
 const OnboardingModal = lazyLoad(() => import('../../apps/tutorials/onboarding/OnboardingModal'));
 const LicensePromptModal = lazyLoad(
index d6f38f12a2918c2d205150ea9fa2b0d2719234b6..02d69091357de283e72288413bd8a94d5e8b6ce3 100644 (file)
@@ -24,7 +24,6 @@ import {
   BranchLike,
   Component,
   CurrentUser,
-  isLoggedIn,
   HomePageType,
   HomePage,
   Measure
@@ -43,6 +42,7 @@ import {
   isPullRequest
 } from '../../../../helpers/branches';
 import { translate } from '../../../../helpers/l10n';
+import { isLoggedIn } from '../../../../helpers/users';
 import { getCurrentUser, Store } from '../../../../store/rootReducer';
 
 interface StateProps {
index 0eda00d3b9597dcee072730e7710e8f9c3536518..67cda337eae14cb2bee6903abfa86a95698172cc 100644 (file)
@@ -27,12 +27,13 @@ import GlobalNavUserContainer from './GlobalNavUserContainer';
 import Search from '../../search/Search';
 import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
 import * as theme from '../../../theme';
-import { CurrentUser, AppState, isLoggedIn } from '../../../types';
+import { CurrentUser, AppState } from '../../../types';
 import NavBar from '../../../../components/nav/NavBar';
 import { lazyLoad } from '../../../../components/lazyLoad';
 import { getCurrentUser, getAppState, Store } from '../../../../store/rootReducer';
 import { SuggestionLink } from '../../embed-docs-modal/SuggestionsProvider';
 import { isSonarCloud } from '../../../../helpers/system';
+import { isLoggedIn } from '../../../../helpers/users';
 import './GlobalNav.css';
 
 const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus');
index 8856840d3f1c710e18579c4426069c033ea837ee..6ff59a0854daaf55e7f21f85f839cd73da9ba9d3 100644 (file)
 import * as React from 'react';
 import * as classNames from 'classnames';
 import { Link } from 'react-router';
-import { isLoggedIn, CurrentUser, AppState, Extension } from '../../../types';
+import { CurrentUser, AppState, Extension } from '../../../types';
 import { translate } from '../../../../helpers/l10n';
 import { getQualityGatesUrl, getBaseUrl } from '../../../../helpers/urls';
 import { isMySet } from '../../../../apps/issues/utils';
 import Dropdown from '../../../../components/controls/Dropdown';
 import DropdownIcon from '../../../../components/icons-components/DropdownIcon';
 import { isSonarCloud } from '../../../../helpers/system';
+import { isLoggedIn } from '../../../../helpers/users';
 
 interface Props {
   appState: Pick<AppState, 'canAdmin' | 'globalPages' | 'organizationsEnabled' | 'qualifiers'>;
index 99d301165609ddbee4d8d43d1d1f0a0247bf0297..4e3f45d648bab0517e87f3dc6f0bacef94fcc643 100644 (file)
@@ -22,12 +22,13 @@ import { Link, withRouter, WithRouterProps } from 'react-router';
 import CreateFormShim from '../../../../apps/portfolio/components/CreateFormShim';
 import Dropdown from '../../../../components/controls/Dropdown';
 import PlusIcon from '../../../../components/icons-components/PlusIcon';
-import { AppState, hasGlobalPermission, LoggedInUser } from '../../../types';
-import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
+import { AppState, LoggedInUser } from '../../../types';
 import { getExtensionStart } from '../../extensions/utils';
-import { isSonarCloud } from '../../../../helpers/system';
-import { translate } from '../../../../helpers/l10n';
 import { getComponentNavigation } from '../../../../api/nav';
+import { translate } from '../../../../helpers/l10n';
+import { isSonarCloud } from '../../../../helpers/system';
+import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
+import { hasGlobalPermission } from '../../../../helpers/users';
 
 interface Props {
   appState: Pick<AppState, 'qualifiers'>;
index 8d11216b9f7dddb4d4be1112d574cc657cf2582f..530f17d248f174fb5625c013cbf8ca4abe0b5c18 100644 (file)
@@ -22,12 +22,13 @@ import { sortBy } from 'lodash';
 import * as PropTypes from 'prop-types';
 import { Link } from 'react-router';
 import * as theme from '../../../theme';
-import { CurrentUser, LoggedInUser, isLoggedIn, Organization } from '../../../types';
+import { CurrentUser, LoggedInUser, Organization } from '../../../types';
 import Avatar from '../../../../components/ui/Avatar';
 import OrganizationListItem from '../../../../components/ui/OrganizationListItem';
 import { translate } from '../../../../helpers/l10n';
 import { getBaseUrl } from '../../../../helpers/urls';
 import Dropdown from '../../../../components/controls/Dropdown';
+import { isLoggedIn } from '../../../../helpers/users';
 
 interface Props {
   appState: { organizationsEnabled?: boolean };
index 9bead9c66d3d78e0c53118e16ec6e4b61a6d1921..b11b91e4591aa3bf419d2e3f01c1f646b02ccd5b 100644 (file)
@@ -72,7 +72,7 @@
 }
 
 .menu > li > a.disabled {
-  color: #bbb !important;
+  color: var(--disableGrayText) !important;
   cursor: not-allowed !important;
   pointer-events: none !important;
 }
index d68a4eb42d8d71209b4e1cb0ddb8ed2e71f0c849..48af0f4a4582bfbf73ee105f6cd4622020bfb4f0 100644 (file)
@@ -249,8 +249,8 @@ label[for] {
 }
 
 .radio-toggle input[type='radio']:disabled + label {
-  color: #bbb;
-  border-color: #ddd;
-  background: #ebebeb;
+  color: var(--disableGrayText);
+  border-color: var(--disableGrayBorder);
+  background: var(--disableGrayBg);
   cursor: not-allowed;
 }
index 8acdf0079e169abdaadfad6f23cad1f256c1d0ff..3c96cec1bdc66ec739977cb863902b3c7c817b51 100644 (file)
@@ -84,12 +84,12 @@ a[class*=' icon-'] {
 }
 
 .icon-checkbox-disabled:before {
-  border: 1px solid #bbb;
+  border: 1px solid var(--disableGrayText);
   cursor: not-allowed;
 }
 
 .icon-checkbox-disabled.icon-checkbox-checked:before {
-  background-color: #bbb;
+  background-color: var(--disableGrayText);
 }
 
 .icon-checkbox-invisible {
index 8498249f48d6409082bbb989fa5494dfd227e343..9cdc8dafa4086523d5dd93299f03668ff08c2ccb 100644 (file)
@@ -299,6 +299,11 @@ td.big-spacer-top {
   align-items: center;
 }
 
+.display-inline-flex-baseline {
+  display: inline-flex !important;
+  align-items: baseline;
+}
+
 .display-inline-flex-center {
   display: inline-flex !important;
   align-items: center;
index 4be34dc3c7ab6fe38bca4e803253ba0203d9b958..748afde887ac7f591298d1148975b40c2c394a2f 100644 (file)
   font-weight: bold;
 }
 
-.sonarcloud .flex-tabs {
-  display: flex;
-  clear: left;
-  margin-bottom: calc(3 * var(--gridSize));
-  border-bottom: 1px solid var(--barBorderColor);
-  font-size: var(--mediumFontSize);
-}
-
-.sonarcloud .flex-tabs > li > a {
-  position: relative;
-  display: block;
-  top: 1px;
-  height: 100%;
-  width: 100%;
-  box-sizing: border-box;
-  color: var(--secondFontColor);
-  font-weight: 600;
-  cursor: pointer;
-  padding-bottom: calc(1.5 * var(--gridSize));
-  border-bottom: 3px solid transparent;
-  transition: color 0.2s ease;
-}
-
-.sonarcloud .flex-tabs > li ~ li {
-  margin-left: calc(4 * var(--gridSize));
-}
-
-.sonarcloud .flex-tabs > li > a:hover {
-  color: var(--baseFontColor);
-}
-
-.sonarcloud .flex-tabs > li > a.selected {
-  color: var(--blue);
-  border-bottom-color: var(--blue);
-}
-
 .beta-badge {
   display: inline-block;
   padding: 2px 4px;
index 8e36aa9859117fc063433c64f7a65fcfe24bfa93..803ba673ad2083ec60543ef0a765256a8aac4b70 100644 (file)
@@ -23,6 +23,14 @@ export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
 
 // Type ordered alphabetically to prevent merge conflicts
 
+export interface AlmApplication extends IdentityProvider {
+  installationUrl: string;
+}
+export interface AlmOrganization extends OrganizationBase {
+  key: string;
+  type: 'ORGANIZATION' | 'USER';
+}
+
 export interface AlmRepository {
   label: string;
   installationKey: string;
@@ -277,10 +285,6 @@ export interface IdentityProvider {
   name: string;
 }
 
-export function isLoggedIn(user: CurrentUser): user is LoggedInUser {
-  return user.isLoggedIn;
-}
-
 export function hasGlobalPermission(user: CurrentUser, permission: string): boolean {
   if (!user.permissions) {
     return false;
@@ -476,6 +480,8 @@ export interface Notification {
 }
 
 export interface Organization extends OrganizationBase {
+  almId?: string;
+  almRepoUrl?: string;
   adminPages?: Extension[];
   canAdmin?: boolean;
   canDelete?: boolean;
index c113f0f46e04ef35e15e1fe1b2065d586b66f468..0c387509bc5b4c51d9ab05db86a43c9c5fbd4f03 100644 (file)
@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
 import SQPageContainer from './components/SQPageContainer';
 import SQStartUsing from './components/SQStartUsing';
 import SQTopNav from './components/SQTopNav';
-import { isLoggedIn } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import { getBaseUrl } from '../../../helpers/urls';
 import './style.css';
 
index 783eba0868d1bca936f0c3b218d69cff2695a400..da9bea478da2cb55333f9ac58b2dd771c868ae8e 100644 (file)
@@ -21,7 +21,7 @@ import * as React from 'react';
 import Helmet from 'react-helmet';
 import { Link } from 'react-router';
 import SQPageContainer from './components/SQPageContainer';
-import { isLoggedIn } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import { getBaseUrl } from '../../../helpers/urls';
 import './style.css';
 
index 54e8e2214cfa28cca3688025f709700c8673c3ff..d5bcb2932a837486a0fdd92a5ca29d4316608be3 100644 (file)
@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
 import SQPageContainer from './components/SQPageContainer';
 import SQStartUsing from './components/SQStartUsing';
 import SQTopNav from './components/SQTopNav';
-import { isLoggedIn } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import { getBaseUrl } from '../../../helpers/urls';
 import './style.css';
 
index 65c70964eb8fd4efc5af363badc685de196cdbe9..9153e103b181746e5dd7fcd82943de66b5217f76 100644 (file)
@@ -23,8 +23,9 @@ import { Link } from 'react-router';
 import { Location } from 'history';
 import SQPageContainer from './components/SQPageContainer';
 import Select from '../../../components/controls/Select';
-import { isLoggedIn, Organization } from '../../../app/types';
 import { Alert } from '../../../components/ui/Alert';
+import { Organization } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import './style.css';
 
 const CATEGORIES = [
index 3f085d6041010f0352993d9dfa360be4645b5f82..f826c42cf8a3e49fa41f3e7248791cb583f0515c 100644 (file)
@@ -24,7 +24,7 @@ import LoginButtons from './components/LoginButtons';
 import Pricing from './components/Pricing';
 import SQPageContainer from './components/SQPageContainer';
 import StartUsing from './components/StartUsing';
-import { isLoggedIn } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import { getBaseUrl } from '../../../helpers/urls';
 import './style.css';
 
index 98510d622fa6130c645832451c5d6a8f1f4cfa8b..a9e35acb2170ba4664b746bf474a12fa2190ad8c 100644 (file)
@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
 import SQPageContainer from './components/SQPageContainer';
 import SQStartUsing from './components/SQStartUsing';
 import SQTopNav from './components/SQTopNav';
-import { isLoggedIn } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 import { getBaseUrl } from '../../../helpers/urls';
 import './style.css';
 
index 7eee54217b6bf98af191cf56b1506d3818b3526c..7d795c8d1f82c8c5d42d859fa595b29d34d020e6 100644 (file)
@@ -41,13 +41,13 @@ import {
   ComponentMeasure,
   ComponentMeasureEnhanced,
   CurrentUser,
-  isLoggedIn,
   Metric,
   Paging,
   MeasureEnhanced,
   Period
 } from '../../../app/types';
 import { RequestData } from '../../../helpers/request';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   branchLike?: BranchLike;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
new file mode 100644 (file)
index 0000000..f7882de
--- /dev/null
@@ -0,0 +1,104 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import ChooseRemoteOrganizationStep from './ChooseRemoteOrganizationStep';
+import OrganizationDetailsStep from './OrganizationDetailsStep';
+import {
+  AlmApplication,
+  AlmOrganization,
+  OrganizationBase,
+  Organization
+} from '../../../app/types';
+import { getBaseUrl } from '../../../helpers/urls';
+import { translate } from '../../../helpers/l10n';
+import { sanitizeAlmId } from '../../../helpers/almIntegrations';
+
+interface Props {
+  almApplication: AlmApplication;
+  almInstallId?: string;
+  almOrganization?: AlmOrganization;
+  createOrganization: (
+    organization: OrganizationBase & { installId?: string }
+  ) => Promise<Organization>;
+  onOrgCreated: (organization: string) => void;
+}
+
+export default class AutoOrganizationCreate extends React.PureComponent<Props> {
+  handleCreateOrganization = (organization: Required<OrganizationBase>) => {
+    if (organization) {
+      return this.props
+        .createOrganization({
+          avatar: organization.avatar,
+          description: organization.description,
+          installId: this.props.almInstallId,
+          key: organization.key,
+          name: organization.name || organization.key,
+          url: organization.url
+        })
+        .then(({ key }) => this.props.onOrgCreated(key));
+    } else {
+      return Promise.reject();
+    }
+  };
+
+  render() {
+    const { almApplication, almInstallId, almOrganization } = this.props;
+    if (almInstallId && almOrganization) {
+      return (
+        <OrganizationDetailsStep
+          description={
+            <p className="huge-spacer-bottom">
+              <FormattedMessage
+                defaultMessage={translate('onboarding.create_organization.import_organization_x')}
+                id="onboarding.create_organization.import_organization_x"
+                values={{
+                  avatar: (
+                    <img
+                      alt={almApplication.name}
+                      className="little-spacer-left"
+                      src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(
+                        almApplication.key
+                      )}.svg`}
+                      width={16}
+                    />
+                  ),
+                  name: <strong>{almOrganization.name}</strong>
+                }}
+              />
+            </p>
+          }
+          finished={false}
+          onContinue={this.handleCreateOrganization}
+          onOpen={() => {}}
+          open={true}
+          organization={almOrganization}
+          submitText={translate('my_account.create_organization')}
+        />
+      );
+    }
+    return (
+      <ChooseRemoteOrganizationStep
+        almApplication={this.props.almApplication}
+        almInstallId={almInstallId}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx
new file mode 100644 (file)
index 0000000..25a091e
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import IdentityProviderLink from '../../../components/ui/IdentityProviderLink';
+import Step from '../../tutorials/components/Step';
+import { translate } from '../../../helpers/l10n';
+import { AlmApplication } from '../../../app/types';
+
+interface Props {
+  almApplication: AlmApplication;
+  almInstallId?: string;
+}
+
+export default class ChooseRemoteOrganizationStep extends React.PureComponent<Props> {
+  renderForm = () => {
+    const { almApplication, almInstallId } = this.props;
+    return (
+      <div className="boxed-group-inner">
+        {almInstallId && (
+          <span className="alert alert-warning markdown big-spacer-bottom width-60">
+            {translate('onboarding.create_organization.import_org_not_found')}
+            <ul>
+              <li>{translate('onboarding.create_organization.import_org_not_found.tips_1')}</li>
+              <li>{translate('onboarding.create_organization.import_org_not_found.tips_2')}</li>
+            </ul>
+          </span>
+        )}
+        <IdentityProviderLink
+          className="display-inline-block"
+          identityProvider={almApplication}
+          small={true}
+          url={almApplication.installationUrl}>
+          {translate(
+            'onboarding.create_organization.choose_organization_button',
+            almApplication.key
+          )}
+        </IdentityProviderLink>
+      </div>
+    );
+  };
+
+  renderResult = () => {
+    return null;
+  };
+
+  render() {
+    return (
+      <Step
+        finished={false}
+        onOpen={() => {}}
+        open={true}
+        renderForm={this.renderForm}
+        renderResult={this.renderResult}
+        stepNumber={1}
+        stepTitle={translate('onboarding.create_organization.import_org_details')}
+      />
+    );
+  }
+}
index b6c0fa25b324f181c6399b5c32cce03aeded8372..2767cdfa399a4d1d43d30a88e6f9a0ef0b98fa87 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import * as classNames from 'classnames';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
 import { Helmet } from 'react-helmet';
 import { FormattedMessage } from 'react-intl';
 import { Link, withRouter, WithRouterProps } from 'react-router';
-import { connect } from 'react-redux';
-import { Dispatch } from 'redux';
-import OrganizationDetailsStep from './OrganizationDetailsStep';
-import PlanStep from './PlanStep';
-import { formatPrice } from './utils';
+import { formatPrice, parseQuery } from './utils';
 import { whenLoggedIn } from './whenLoggedIn';
+import AutoOrganizationCreate from './AutoOrganizationCreate';
+import ManualOrganizationCreate from './ManualOrganizationCreate';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import Tabs from '../../../components/controls/Tabs';
+import { getAlmAppInfo, getAlmOrganization } from '../../../api/alm-integration';
 import { getSubscriptionPlans } from '../../../api/billing';
-import { OrganizationBase, Organization, SubscriptionPlan } from '../../../app/types';
+import {
+  LoggedInUser,
+  Organization,
+  SubscriptionPlan,
+  AlmApplication,
+  AlmOrganization,
+  OrganizationBase
+} from '../../../app/types';
+import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
 import { getOrganizationUrl } from '../../../helpers/urls';
 import * as api from '../../../api/organizations';
@@ -38,27 +50,26 @@ import '../../tutorials/styles.css'; // TODO remove me
 
 interface Props {
   createOrganization: (organization: OrganizationBase) => Promise<Organization>;
+  currentUser: LoggedInUser;
   deleteOrganization: (key: string) => Promise<void>;
 }
 
-enum Step {
-  OrganizationDetails,
-  Plan
-}
-
 interface State {
+  almApplication?: AlmApplication;
+  almOrganization?: AlmOrganization;
   loading: boolean;
   organization?: Organization;
-  step: Step;
   subscriptionPlans?: SubscriptionPlan[];
 }
 
+interface LocationState {
+  paid?: boolean;
+  tab?: 'auto' | 'manual';
+}
+
 export class CreateOrganization extends React.PureComponent<Props & WithRouterProps, State> {
   mounted = false;
-  state: State = {
-    loading: true,
-    step: Step.OrganizationDetails
-  };
+  state: State = { loading: true };
 
   componentDidMount() {
     this.mounted = true;
@@ -66,7 +77,16 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     if (document.documentElement) {
       document.documentElement.classList.add('white-page');
     }
-    this.fetchSubscriptionPlans();
+    const initRequests = [this.fetchSubscriptionPlans()];
+    if (hasAdvancedALMIntegration(this.props.currentUser)) {
+      initRequests.push(this.fetchAlmApplication());
+
+      const query = parseQuery(this.props.location.query);
+      if (query.almInstallId) {
+        initRequests.push(this.fetchAlmOrganization(query.almInstallId));
+      }
+    }
+    Promise.all(initRequests).then(this.stopLoading, this.stopLoading);
   }
 
   componentWillUnmount() {
@@ -74,79 +94,63 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     document.body.classList.remove('white-page');
   }
 
-  fetchSubscriptionPlans = () => {
-    getSubscriptionPlans().then(
-      subscriptionPlans => {
-        if (this.mounted) {
-          this.setState({ loading: false, subscriptionPlans });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
+  fetchAlmApplication = () => {
+    return getAlmAppInfo().then(({ application }) => {
+      if (this.mounted) {
+        this.setState({ almApplication: application });
       }
-    );
-  };
-
-  finishCreation = (key: string) => {
-    this.props.router.push({
-      pathname: getOrganizationUrl(key),
-      state: { justCreated: true }
     });
   };
 
-  handleOrganizationDetailsStepOpen = () => {
-    this.setState({ step: Step.OrganizationDetails });
+  fetchAlmOrganization = (installationId: string) => {
+    return getAlmOrganization({ installationId }).then(almOrganization => {
+      if (this.mounted) {
+        this.setState({ almOrganization });
+      }
+    });
   };
 
-  handleOrganizationDetailsFinish = (organization: Required<OrganizationBase>) => {
-    this.setState({ organization, step: Step.Plan });
-    return Promise.resolve();
+  fetchSubscriptionPlans = () => {
+    return getSubscriptionPlans().then(subscriptionPlans => {
+      if (this.mounted) {
+        this.setState({ subscriptionPlans });
+      }
+    });
   };
 
-  handlePaidPlanChoose = () => {
-    if (this.state.organization) {
-      this.finishCreation(this.state.organization.key);
-    }
+  handleOrgCreated = (organization: string) => {
+    this.props.router.push({
+      pathname: getOrganizationUrl(organization),
+      state: { justCreated: true }
+    });
   };
 
-  handleFreePlanChoose = () => {
-    return this.createOrganization().then(key => {
-      this.finishCreation(key);
-    });
+  onTabChange = (tab: 'auto' | 'manual') => {
+    this.updateUrl({ tab });
   };
 
-  createOrganization = () => {
-    const { organization } = this.state;
-    if (organization) {
-      return this.props
-        .createOrganization({
-          avatar: organization.avatar,
-          description: organization.description,
-          key: organization.key,
-          name: organization.name || organization.key,
-          url: organization.url
-        })
-        .then(({ key }) => key);
-    } else {
-      return Promise.reject();
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
     }
   };
 
-  deleteOrganization = () => {
-    const { organization } = this.state;
-    if (organization) {
-      this.props.deleteOrganization(organization.key).catch(() => {});
-    }
+  updateUrl = (state: Partial<LocationState> = {}) => {
+    this.props.router.replace({
+      pathname: this.props.location.pathname,
+      state: { ...(this.props.location.state || {}), ...state }
+    });
   };
 
   render() {
     const { location } = this.props;
-    const { loading, subscriptionPlans } = this.state;
+    const { almApplication, loading, subscriptionPlans } = this.state;
+    const state = (location.state || {}) as LocationState;
+    const query = parseQuery(location.query);
     const header = translate('onboarding.create_organization.page.header');
     const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price;
     const formattedPrice = formatPrice(startedPrice);
+    const showManualTab = state.tab === 'manual' && !query.almInstallId;
 
     return (
       <>
@@ -174,27 +178,59 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
           </header>
 
           {loading ? (
-            <i className="spinner" />
+            <DeferredSpinner />
           ) : (
             <>
-              <OrganizationDetailsStep
-                finished={this.state.organization !== undefined}
-                onContinue={this.handleOrganizationDetailsFinish}
-                onOpen={this.handleOrganizationDetailsStepOpen}
-                open={this.state.step === Step.OrganizationDetails}
-                organization={this.state.organization}
-              />
-
-              {subscriptionPlans !== undefined && (
-                <PlanStep
-                  createOrganization={this.createOrganization}
-                  deleteOrganization={this.deleteOrganization}
-                  onFreePlanChoose={this.handleFreePlanChoose}
-                  onPaidPlanChoose={this.handlePaidPlanChoose}
-                  onlyPaid={location.state && location.state.paid === true}
-                  open={this.state.step === Step.Plan}
-                  startingPrice={formattedPrice}
-                  subscriptionPlans={subscriptionPlans}
+              {almApplication && (
+                <Tabs
+                  onChange={this.onTabChange}
+                  selected={showManualTab ? 'manual' : 'auto'}
+                  tabs={[
+                    {
+                      key: 'auto',
+                      node: (
+                        <>
+                          {translate(
+                            'onboarding.create_organization.import_organization',
+                            almApplication.key
+                          )}
+                          <span
+                            className={classNames(
+                              'rounded alert alert-small spacer-left display-inline-block',
+                              {
+                                'alert-info': !showManualTab,
+                                'alert-muted': showManualTab
+                              }
+                            )}>
+                            {translate('beta')}
+                          </span>
+                        </>
+                      )
+                    },
+                    {
+                      disabled: Boolean(query.almInstallId),
+                      key: 'manual',
+                      node: translate('onboarding.create_organization.create_manually')
+                    }
+                  ]}
+                />
+              )}
+
+              {showManualTab || !almApplication ? (
+                <ManualOrganizationCreate
+                  createOrganization={this.props.createOrganization}
+                  deleteOrganization={this.props.deleteOrganization}
+                  onOrgCreated={this.handleOrgCreated}
+                  onlyPaid={state.paid}
+                  subscriptionPlans={this.state.subscriptionPlans}
+                />
+              ) : (
+                <AutoOrganizationCreate
+                  almApplication={almApplication}
+                  almInstallId={query.almInstallId}
+                  almOrganization={this.state.almOrganization}
+                  createOrganization={this.props.createOrganization}
+                  onOrgCreated={this.handleOrgCreated}
                 />
               )}
             </>
@@ -205,7 +241,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
   }
 }
 
-function createOrganization(organization: OrganizationBase) {
+function createOrganization(organization: OrganizationBase & { installId?: string }) {
   return (dispatch: Dispatch) => {
     return api.createOrganization(organization).then((organization: Organization) => {
       dispatch(actions.createOrganization(organization));
@@ -228,8 +264,10 @@ const mapDispatchToProps = {
 };
 
 export default whenLoggedIn(
-  connect(
-    null,
-    mapDispatchToProps
-  )(withRouter(CreateOrganization))
+  withRouter(
+    connect(
+      null,
+      mapDispatchToProps
+    )(CreateOrganization)
+  )
 );
diff --git a/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx b/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
new file mode 100644 (file)
index 0000000..6bf245e
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import OrganizationDetailsStep from './OrganizationDetailsStep';
+import PlanStep from './PlanStep';
+import { formatPrice } from './utils';
+import { OrganizationBase, Organization, SubscriptionPlan } from '../../../app/types';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  createOrganization: (organization: OrganizationBase) => Promise<Organization>;
+  deleteOrganization: (key: string) => Promise<void>;
+  onOrgCreated: (organization: string) => void;
+  onlyPaid?: boolean;
+  subscriptionPlans?: SubscriptionPlan[];
+}
+
+enum Step {
+  OrganizationDetails,
+  Plan
+}
+
+interface State {
+  organization?: Organization;
+  step: Step;
+}
+
+export default class ManualOrganizationCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { step: Step.OrganizationDetails };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleOrganizationDetailsStepOpen = () => {
+    this.setState({ step: Step.OrganizationDetails });
+  };
+
+  handleOrganizationDetailsFinish = (organization: Required<OrganizationBase>) => {
+    this.setState({ organization, step: Step.Plan });
+    return Promise.resolve();
+  };
+
+  handlePaidPlanChoose = () => {
+    if (this.state.organization) {
+      this.props.onOrgCreated(this.state.organization.key);
+    }
+  };
+
+  handleFreePlanChoose = () => {
+    return this.createOrganization().then(key => {
+      this.props.onOrgCreated(key);
+    });
+  };
+
+  createOrganization = () => {
+    const { organization } = this.state;
+    if (organization) {
+      return this.props
+        .createOrganization({
+          avatar: organization.avatar,
+          description: organization.description,
+          key: organization.key,
+          name: organization.name || organization.key,
+          url: organization.url
+        })
+        .then(({ key }) => key);
+    } else {
+      return Promise.reject();
+    }
+  };
+
+  deleteOrganization = () => {
+    const { organization } = this.state;
+    if (organization) {
+      this.props.deleteOrganization(organization.key).catch(() => {});
+    }
+  };
+
+  render() {
+    const { subscriptionPlans } = this.props;
+    const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price;
+    const formattedPrice = formatPrice(startedPrice);
+
+    return (
+      <>
+        <OrganizationDetailsStep
+          finished={this.state.organization !== undefined}
+          onContinue={this.handleOrganizationDetailsFinish}
+          onOpen={this.handleOrganizationDetailsStepOpen}
+          open={this.state.step === Step.OrganizationDetails}
+          organization={this.state.organization}
+          submitText={translate('continue')}
+        />
+
+        {subscriptionPlans !== undefined && (
+          <PlanStep
+            createOrganization={this.createOrganization}
+            deleteOrganization={this.deleteOrganization}
+            onFreePlanChoose={this.handleFreePlanChoose}
+            onPaidPlanChoose={this.handlePaidPlanChoose}
+            onlyPaid={this.props.onlyPaid}
+            open={this.state.step === Step.Plan}
+            startingPrice={formattedPrice}
+            subscriptionPlans={subscriptionPlans}
+          />
+        )}
+      </>
+    );
+  }
+}
index 8357fa5469825176ce57d5dbaf204ddc844cd1f0..9526e064562938635fb505c6e54185d49fa203dd 100644 (file)
@@ -29,6 +29,7 @@ interface Props {
   error: string | undefined;
   id: string;
   isSubmitting: boolean;
+  isValidating: boolean;
   label: React.ReactNode;
   name: string;
   onBlur: React.FocusEventHandler;
@@ -39,12 +40,12 @@ interface Props {
 }
 
 export default function OrganizationDetailsInput(props: Props) {
-  const hasError = props.dirty && props.touched && props.error !== undefined;
+  const hasError = props.dirty && props.touched && !props.isValidating && props.error !== undefined;
   const isValid = props.dirty && props.touched && props.error === undefined;
   return (
     <div>
       <label htmlFor={props.id}>
-        {props.label}
+        <strong>{props.label}</strong>
         {props.required && <em className="mandatory">*</em>}
       </label>
       <div className="little-spacer-top spacer-bottom">
index cf5026f87ae6ce5c1aaee7c7fe6839bd02004ef7..75f1b75d17bceda6a29c2f5ca2db56018d6335e9 100644 (file)
@@ -26,6 +26,7 @@ import { translate } from '../../../helpers/l10n';
 import { ResetButtonLink, SubmitButton } from '../../../components/ui/buttons';
 import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon';
 import DropdownIcon from '../../../components/icons-components/DropdownIcon';
+import { getHostUrl } from '../../../helpers/urls';
 import { OrganizationBase } from '../../../app/types';
 import { getOrganization } from '../../../api/organizations';
 
@@ -40,11 +41,13 @@ const initialValues: Values = {
 };
 
 interface Props {
+  description?: React.ReactNode;
   finished: boolean;
   onContinue: (organization: Required<OrganizationBase>) => Promise<void>;
   onOpen: () => void;
   open: boolean;
   organization?: OrganizationBase & { key: string };
+  submitText: string;
 }
 
 interface State {
@@ -118,10 +121,17 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
       handleChange,
       isSubmitting,
       isValid,
+      isValidating,
       touched,
       values
     } = props;
-    const commonProps = { dirty, isSubmitting, onBlur: handleBlur, onChange: handleChange };
+    const commonProps = {
+      dirty,
+      isValidating,
+      isSubmitting,
+      onBlur: handleBlur,
+      onChange: handleChange
+    };
     return (
       <>
         <OrganizationDetailsInput
@@ -134,7 +144,14 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
           required={true}
           touched={touched.key}
           value={values.key}>
-          {props => <input autoFocus={true} maxLength={255} {...props} />}
+          {props => (
+            <div className="display-inline-flex-baseline">
+              <span className="little-spacer-right">
+                {getHostUrl().replace(/https*:\/\//, '') + '/organizations/'}
+              </span>
+              <input autoFocus={true} maxLength={255} {...props} />
+            </div>
+          )}
         </OrganizationDetailsInput>
         <div className="big-spacer-top">
           <ResetButtonLink onClick={this.handleAdditionalClick}>
@@ -170,7 +187,19 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
               name="avatar"
               touched={touched.avatar && values.avatar !== ''}
               value={values.avatar}>
-              {props => <input {...props} />}
+              {props => (
+                <>
+                  {values.avatar && (
+                    <img
+                      alt=""
+                      className="display-block spacer-bottom rounded"
+                      src={values.avatar}
+                      width={48}
+                    />
+                  )}
+                  <input {...props} />
+                </>
+              )}
             </OrganizationDetailsInput>
           </div>
           <div className="big-spacer-top">
@@ -199,7 +228,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
           </div>
         </div>
         <div className="big-spacer-top">
-          <SubmitButton disabled={isSubmitting || !isValid}>{translate('continue')}</SubmitButton>
+          <SubmitButton disabled={isSubmitting || !isValid}>{this.props.submitText}</SubmitButton>
         </div>
       </>
     );
@@ -208,6 +237,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
   renderForm = () => {
     return (
       <div className="boxed-group-inner">
+        {this.props.description}
         <ValidationForm<Values>
           initialValues={this.getInitialValues()}
           isInitialValid={this.props.organization !== undefined}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
new file mode 100644 (file)
index 0000000..8beb62e
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import AutoOrganizationCreate from '../AutoOrganizationCreate';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+const organization = {
+  avatar: 'http://example.com/avatar',
+  description: 'description-foo',
+  key: 'key-foo',
+  name: 'name-foo',
+  url: 'http://example.com/foo'
+};
+
+it('should render with import org button', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render prefilled and create org', async () => {
+  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
+  const onOrgCreated = jest.fn();
+  const wrapper = shallowRender({
+    almInstallId: 'id-foo',
+    almOrganization: {
+      ...organization,
+      type: 'ORGANIZATION'
+    },
+    createOrganization,
+    onOrgCreated
+  });
+
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  await waitAndUpdate(wrapper);
+
+  expect(createOrganization).toBeCalledWith({ ...organization, installId: 'id-foo' });
+  expect(onOrgCreated).toBeCalledWith('foo');
+});
+
+function shallowRender(props: Partial<AutoOrganizationCreate['props']> = {}) {
+  return shallow(
+    <AutoOrganizationCreate
+      almApplication={{
+        backgroundColor: '#0052CC',
+        iconPath: '"/static/authbitbucket/bitbucket.svg"',
+        installationUrl: 'https://bitbucket.org/install/app',
+        key: 'bitbucket',
+        name: 'BitBucket'
+      }}
+      createOrganization={jest.fn()}
+      onOrgCreated={jest.fn()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx
new file mode 100644 (file)
index 0000000..a86ab8d
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ChooseRemoteOrganizationStep from '../ChooseRemoteOrganizationStep';
+
+it('should render', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should display a warning message', () => {
+  expect(shallowRender({ almInstallId: 'foo' }).find('.alert-warning')).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<ChooseRemoteOrganizationStep['props']> = {}) {
+  return shallow(
+    <ChooseRemoteOrganizationStep
+      almApplication={{
+        backgroundColor: 'blue',
+        iconPath: 'icon/path',
+        installationUrl: 'https://alm.application.url',
+        key: 'github',
+        name: 'GitHub'
+      }}
+      {...props}
+    />
+  ).dive();
+}
index 6adb8f9d5206acff21b96131a877b6838da1e132..9c1e58367fb206c2e74d30500efaa079e86fe7ad 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { Location } from 'history';
 import { shallow } from 'enzyme';
 import { CreateOrganization } from '../CreateOrganization';
 import { mockRouter, waitAndUpdate } from '../../../../helpers/testUtils';
+import { LoggedInUser } from '../../../../app/types';
 
 jest.mock('../../../../api/billing', () => ({
   getSubscriptionPlans: jest
@@ -28,73 +30,93 @@ jest.mock('../../../../api/billing', () => ({
     .mockResolvedValue([{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }])
 }));
 
-const organization = {
-  avatar: 'http://example.com/avatar',
-  description: 'description-foo',
-  key: 'key-foo',
-  name: 'name-foo',
-  url: 'http://example.com/foo'
+jest.mock('../../../../api/alm-integration', () => ({
+  getAlmAppInfo: jest.fn().mockResolvedValue({
+    application: {
+      installationUrl: 'https://alm.installation.url',
+      backgroundColor: 'blue',
+      iconPath: 'icon/path',
+      key: 'github',
+      name: 'GitHub'
+    }
+  }),
+  getAlmOrganization: jest.fn().mockResolvedValue({
+    key: 'sonarsource',
+    name: 'SonarSource',
+    description: 'Continuous Code Quality',
+    url: 'https://www.sonarsource.com',
+    avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
+    type: 'ORGANIZATION'
+  })
+}));
+
+const user: LoggedInUser = {
+  groups: [],
+  isLoggedIn: true,
+  login: 'luke',
+  name: 'Skywalker',
+  scmAccounts: [],
+  showOnboardingTutorial: false
 };
 
-it('should render and create organization', async () => {
-  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
-  const router = mockRouter();
-  const wrapper = shallow(
-    // @ts-ignore avoid passing everything from WithRouterProps
-    <CreateOrganization createOrganization={createOrganization} location={{}} router={router} />
-  );
+it('should render with manual tab displayed', async () => {
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
+});
 
-  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+it('should preselect paid plan on manual creation', async () => {
+  const location = { state: { paid: true } };
+  // @ts-ignore avoid passing everything from WithRouterProps
+  const wrapper = shallowRender({ location });
   await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('ManualOrganizationCreate').prop('onlyPaid')).toBe(true);
+});
 
-  wrapper.find('PlanStep').prop<Function>('onFreePlanChoose')();
+it('should render with auto tab displayed', async () => {
+  const wrapper = shallowRender({ currentUser: { ...user, externalProvider: 'github' } });
   await waitAndUpdate(wrapper);
-  expect(createOrganization).toBeCalledWith(organization);
-  expect(router.push).toBeCalledWith({
-    pathname: '/organizations/foo',
-    state: { justCreated: true }
-  });
+  expect(wrapper).toMatchSnapshot();
 });
 
-it('should preselect paid plan', async () => {
-  const router = mockRouter();
-  const location = { state: { paid: true } };
-  const wrapper = shallow(
-    // @ts-ignore avoid passing everything from WithRouterProps
-    <CreateOrganization createOrganization={jest.fn()} location={location} router={router} />
-  );
+it('should render with auto tab selected and manual disabled', async () => {
+  const wrapper = shallowRender({
+    currentUser: { ...user, externalProvider: 'github' },
+    location: { query: { installation_id: 'foo' } } as Location // eslint-disable-line camelcase
+  });
   await waitAndUpdate(wrapper);
-  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should switch tabs', async () => {
+  const replace = jest.fn();
+  const wrapper = shallowRender({
+    currentUser: { ...user, externalProvider: 'github' },
+    router: mockRouter({ replace })
+  });
+
+  replace.mockImplementation(location => {
+    wrapper.setProps({ location }).update();
+  });
+
   await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
 
-  expect(wrapper.find('PlanStep').prop('onlyPaid')).toBe(true);
+  (wrapper.find('Tabs').prop('onChange') as Function)('manual');
+  expect(wrapper.find('ManualOrganizationCreate').exists()).toBeTruthy();
+  (wrapper.find('Tabs').prop('onChange') as Function)('auto');
+  expect(wrapper.find('AutoOrganizationCreate').exists()).toBeTruthy();
 });
 
-it('should roll back after upgrade failure', async () => {
-  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
-  const deleteOrganization = jest.fn().mockResolvedValue(undefined);
-  const router = mockRouter();
-  const wrapper = shallow(
+function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
+  return shallow(
     <CreateOrganization
-      createOrganization={createOrganization}
-      deleteOrganization={deleteOrganization}
+      currentUser={user}
+      {...props}
       // @ts-ignore avoid passing everything from WithRouterProps
       location={{}}
-      // @ts-ignore avoid passing everything from WithRouterProps
-      router={router}
+      router={mockRouter()}
+      {...props}
     />
   );
-  await waitAndUpdate(wrapper);
-
-  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
-  await waitAndUpdate(wrapper);
-
-  wrapper.find('PlanStep').prop<Function>('createOrganization')();
-  expect(createOrganization).toBeCalledWith(organization);
-
-  wrapper.find('PlanStep').prop<Function>('deleteOrganization')();
-  expect(deleteOrganization).toBeCalledWith(organization.key);
-});
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
new file mode 100644 (file)
index 0000000..6226f81
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ManualOrganizationCreate from '../ManualOrganizationCreate';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+const organization = {
+  avatar: 'http://example.com/avatar',
+  description: 'description-foo',
+  key: 'key-foo',
+  name: 'name-foo',
+  url: 'http://example.com/foo'
+};
+
+it('should render and create organization', async () => {
+  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
+  const onOrgCreated = jest.fn();
+  const wrapper = shallowRender({ createOrganization, onOrgCreated });
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.find('PlanStep').prop<Function>('onFreePlanChoose')();
+  await waitAndUpdate(wrapper);
+  expect(createOrganization).toBeCalledWith(organization);
+  expect(onOrgCreated).toBeCalledWith('foo');
+});
+
+it('should preselect paid plan', async () => {
+  const wrapper = shallowRender({ onlyPaid: true });
+
+  await waitAndUpdate(wrapper);
+  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find('PlanStep').prop('onlyPaid')).toBe(true);
+});
+
+it('should roll back after upgrade failure', async () => {
+  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
+  const deleteOrganization = jest.fn().mockResolvedValue(undefined);
+  const wrapper = shallowRender({ createOrganization, deleteOrganization });
+  await waitAndUpdate(wrapper);
+
+  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  await waitAndUpdate(wrapper);
+
+  wrapper.find('PlanStep').prop<Function>('createOrganization')();
+  expect(createOrganization).toBeCalledWith(organization);
+
+  wrapper.find('PlanStep').prop<Function>('deleteOrganization')();
+  expect(deleteOrganization).toBeCalledWith(organization.key);
+});
+
+function shallowRender(props: Partial<ManualOrganizationCreate['props']> = {}) {
+  return shallow(
+    <ManualOrganizationCreate
+      createOrganization={jest.fn()}
+      deleteOrganization={jest.fn()}
+      onOrgCreated={jest.fn()}
+      subscriptionPlans={[{ maxNcloc: 100000, price: 10 }, { maxNcloc: 250000, price: 75 }]}
+      {...props}
+    />
+  );
+}
index 38629f876997eba5d0b2655307463fec50233a59..4ddeac5d9139adf770c2cfa8e650fb75f1e06c61 100644 (file)
@@ -30,6 +30,7 @@ it('should render', () => {
         error="This field is bad!"
         id="field"
         isSubmitting={true}
+        isValidating={false}
         label="Label"
         name="field"
         onBlur={jest.fn()}
index aad43c0a619b5aca31e22b3908f23cefdea4dda6..f8748b45aecfd00416973a41486a2ea6842e689e 100644 (file)
@@ -38,6 +38,7 @@ it('should render form', () => {
       onContinue={jest.fn()}
       onOpen={jest.fn()}
       open={true}
+      submitText="continue"
     />
   );
   expect(wrapper).toMatchSnapshot();
@@ -58,22 +59,29 @@ it('should render form', () => {
   ).toBe(false);
 });
 
-it('should validate', () => {
+it('should validate', async () => {
   const wrapper = shallow(
     <OrganizationDetailsStep
       finished={false}
       onContinue={jest.fn()}
       onOpen={jest.fn()}
       open={true}
+      submitText="continue"
     />
   );
   const instance = wrapper.instance() as OrganizationDetailsStep;
 
-  expect(
-    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
+  await expect(
+    instance.handleValidate({
+      avatar: '',
+      description: '',
+      name: '',
+      key: 'foo',
+      url: ''
+    })
   ).resolves.toEqual({});
 
-  expect(
+  await expect(
     instance.handleValidate({
       avatar: '',
       description: '',
@@ -81,13 +89,21 @@ it('should validate', () => {
       key: 'x'.repeat(256),
       url: ''
     })
-  ).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.error' });
+  ).rejects.toEqual({
+    key: 'onboarding.create_organization.organization_name.error'
+  });
 
-  expect(
-    instance.handleValidate({ avatar: 'bla', description: '', name: '', key: 'foo', url: '' })
+  await expect(
+    instance.handleValidate({
+      avatar: 'bla',
+      description: '',
+      name: '',
+      key: 'foo',
+      url: ''
+    })
   ).rejects.toEqual({ avatar: 'onboarding.create_organization.avatar.error' });
 
-  expect(
+  await expect(
     instance.handleValidate({
       avatar: '',
       description: '',
@@ -95,16 +111,34 @@ it('should validate', () => {
       key: 'foo',
       url: ''
     })
-  ).rejects.toEqual({ name: 'onboarding.create_organization.display_name.error' });
+  ).rejects.toEqual({
+    name: 'onboarding.create_organization.display_name.error'
+  });
 
-  expect(
-    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: 'bla' })
-  ).rejects.toEqual({ url: 'onboarding.create_organization.url.error' });
+  await expect(
+    instance.handleValidate({
+      avatar: '',
+      description: '',
+      name: '',
+      key: 'foo',
+      url: 'bla'
+    })
+  ).rejects.toEqual({
+    url: 'onboarding.create_organization.url.error'
+  });
 
   (getOrganization as jest.Mock).mockResolvedValue({});
-  expect(
-    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
-  ).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.taken' });
+  await expect(
+    instance.handleValidate({
+      avatar: '',
+      description: '',
+      name: '',
+      key: 'foo',
+      url: ''
+    })
+  ).rejects.toEqual({
+    key: 'onboarding.create_organization.organization_name.taken'
+  });
 });
 
 it('should render result', () => {
@@ -115,6 +149,7 @@ it('should render result', () => {
       onOpen={jest.fn()}
       open={false}
       organization={{ avatar: '', description: '', key: 'org', name: 'Organization', url: '' }}
+      submitText="continue"
     />
   );
   expect(wrapper.dive()).toMatchSnapshot();
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
new file mode 100644 (file)
index 0000000..a57042c
--- /dev/null
@@ -0,0 +1,58 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render prefilled and create org 1`] = `
+<OrganizationDetailsStep
+  description={
+    <p
+      className="huge-spacer-bottom"
+    >
+      <FormattedMessage
+        defaultMessage="onboarding.create_organization.import_organization_x"
+        id="onboarding.create_organization.import_organization_x"
+        values={
+          Object {
+            "avatar": <img
+              alt="BitBucket"
+              className="little-spacer-left"
+              src="/images/sonarcloud/bitbucket.svg"
+              width={16}
+            />,
+            "name": <strong>
+              name-foo
+            </strong>,
+          }
+        }
+      />
+    </p>
+  }
+  finished={false}
+  onContinue={[Function]}
+  onOpen={[Function]}
+  open={true}
+  organization={
+    Object {
+      "avatar": "http://example.com/avatar",
+      "description": "description-foo",
+      "key": "key-foo",
+      "name": "name-foo",
+      "type": "ORGANIZATION",
+      "url": "http://example.com/foo",
+    }
+  }
+  submitText="my_account.create_organization"
+/>
+`;
+
+exports[`should render with import org button 1`] = `
+<ChooseRemoteOrganizationStep
+  almApplication={
+    Object {
+      "backgroundColor": "#0052CC",
+      "iconPath": "\\"/static/authbitbucket/bitbucket.svg\\"",
+      "installationUrl": "https://bitbucket.org/install/app",
+      "key": "bitbucket",
+      "name": "BitBucket",
+    }
+  }
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap
new file mode 100644 (file)
index 0000000..ec99dad
--- /dev/null
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display a warning message 1`] = `
+<span
+  className="alert alert-warning markdown big-spacer-bottom width-60"
+>
+  onboarding.create_organization.import_org_not_found
+  <ul>
+    <li>
+      onboarding.create_organization.import_org_not_found.tips_1
+    </li>
+    <li>
+      onboarding.create_organization.import_org_not_found.tips_2
+    </li>
+  </ul>
+</span>
+`;
+
+exports[`should render 1`] = `
+<div
+  className="boxed-group onboarding-step is-open"
+>
+  <div
+    className="onboarding-step-number"
+  >
+    1
+  </div>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      onboarding.create_organization.import_org_details
+    </h2>
+  </div>
+  <div
+    className=""
+  >
+    <div
+      className="boxed-group-inner"
+    >
+      <IdentityProviderLink
+        className="display-inline-block"
+        identityProvider={
+          Object {
+            "backgroundColor": "blue",
+            "iconPath": "icon/path",
+            "installationUrl": "https://alm.application.url",
+            "key": "github",
+            "name": "GitHub",
+          }
+        }
+        small={true}
+        url="https://alm.application.url"
+      >
+        onboarding.create_organization.choose_organization_button.github
+      </IdentityProviderLink>
+    </div>
+  </div>
+</div>
+`;
index 867e8c9f4a00955e33436cfb4cd1944c0bab60da..f2da7e6a62c3156ebd2e6632bac7d68712f7d8ea 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render and create organization 1`] = `
+exports[`should render with auto tab displayed 1`] = `
 <Fragment>
   <HelmetWrapper
     defer={true}
@@ -42,37 +42,47 @@ exports[`should render and create organization 1`] = `
         />
       </p>
     </header>
-    <OrganizationDetailsStep
-      finished={false}
-      onContinue={[Function]}
-      onOpen={[Function]}
-      open={true}
-    />
-    <PlanStep
-      createOrganization={[Function]}
-      deleteOrganization={[Function]}
-      onFreePlanChoose={[Function]}
-      onPaidPlanChoose={[Function]}
-      open={false}
-      startingPrice="billing.price_format.10"
-      subscriptionPlans={
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
         Array [
           Object {
-            "maxNcloc": 100000,
-            "price": 10,
+            "key": "auto",
+            "node": <React.Fragment>
+              onboarding.create_organization.import_organization.github
+              <span
+                className="rounded alert alert-small spacer-left display-inline-block alert-info"
+              >
+                beta
+              </span>
+            </React.Fragment>,
           },
           Object {
-            "maxNcloc": 250000,
-            "price": 75,
+            "disabled": false,
+            "key": "manual",
+            "node": "onboarding.create_organization.create_manually",
           },
         ]
       }
     />
+    <AutoOrganizationCreate
+      almApplication={
+        Object {
+          "backgroundColor": "blue",
+          "iconPath": "icon/path",
+          "installationUrl": "https://alm.installation.url",
+          "key": "github",
+          "name": "GitHub",
+        }
+      }
+      onOrgCreated={[Function]}
+    />
   </div>
 </Fragment>
 `;
 
-exports[`should render and create organization 2`] = `
+exports[`should render with auto tab selected and manual disabled 1`] = `
 <Fragment>
   <HelmetWrapper
     defer={true}
@@ -114,28 +124,101 @@ exports[`should render and create organization 2`] = `
         />
       </p>
     </header>
-    <OrganizationDetailsStep
-      finished={true}
-      onContinue={[Function]}
-      onOpen={[Function]}
-      open={false}
-      organization={
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
+        Array [
+          Object {
+            "key": "auto",
+            "node": <React.Fragment>
+              onboarding.create_organization.import_organization.github
+              <span
+                className="rounded alert alert-small spacer-left display-inline-block alert-info"
+              >
+                beta
+              </span>
+            </React.Fragment>,
+          },
+          Object {
+            "disabled": true,
+            "key": "manual",
+            "node": "onboarding.create_organization.create_manually",
+          },
+        ]
+      }
+    />
+    <AutoOrganizationCreate
+      almApplication={
         Object {
-          "avatar": "http://example.com/avatar",
-          "description": "description-foo",
-          "key": "key-foo",
-          "name": "name-foo",
-          "url": "http://example.com/foo",
+          "backgroundColor": "blue",
+          "iconPath": "icon/path",
+          "installationUrl": "https://alm.installation.url",
+          "key": "github",
+          "name": "GitHub",
         }
       }
+      almInstallId="foo"
+      almOrganization={
+        Object {
+          "avatar": "https://avatars3.githubusercontent.com/u/37629810?v=4",
+          "description": "Continuous Code Quality",
+          "key": "sonarsource",
+          "name": "SonarSource",
+          "type": "ORGANIZATION",
+          "url": "https://www.sonarsource.com",
+        }
+      }
+      onOrgCreated={[Function]}
     />
-    <PlanStep
-      createOrganization={[Function]}
-      deleteOrganization={[Function]}
-      onFreePlanChoose={[Function]}
-      onPaidPlanChoose={[Function]}
-      open={true}
-      startingPrice="billing.price_format.10"
+  </div>
+</Fragment>
+`;
+
+exports[`should render with manual tab displayed 1`] = `
+<Fragment>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="onboarding.create_organization.page.header"
+    titleTemplate="%s"
+  />
+  <div
+    className="sonarcloud page page-limited"
+  >
+    <header
+      className="page-header"
+    >
+      <h1
+        className="page-title big-spacer-bottom"
+      >
+        onboarding.create_organization.page.header
+      </h1>
+      <p
+        className="page-description"
+      >
+        <FormattedMessage
+          defaultMessage="onboarding.create_organization.page.description"
+          id="onboarding.create_organization.page.description"
+          values={
+            Object {
+              "break": <br />,
+              "more": <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                target="_blank"
+                to="/documentation/sonarcloud-pricing/"
+              >
+                learn_more
+              </Link>,
+              "price": "billing.price_format.10",
+            }
+          }
+        />
+      </p>
+    </header>
+    <ManualOrganizationCreate
+      onOrgCreated={[Function]}
       subscriptionPlans={
         Array [
           Object {
@@ -152,3 +235,85 @@ exports[`should render and create organization 2`] = `
   </div>
 </Fragment>
 `;
+
+exports[`should switch tabs 1`] = `
+<Fragment>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="onboarding.create_organization.page.header"
+    titleTemplate="%s"
+  />
+  <div
+    className="sonarcloud page page-limited"
+  >
+    <header
+      className="page-header"
+    >
+      <h1
+        className="page-title big-spacer-bottom"
+      >
+        onboarding.create_organization.page.header
+      </h1>
+      <p
+        className="page-description"
+      >
+        <FormattedMessage
+          defaultMessage="onboarding.create_organization.page.description"
+          id="onboarding.create_organization.page.description"
+          values={
+            Object {
+              "break": <br />,
+              "more": <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                target="_blank"
+                to="/documentation/sonarcloud-pricing/"
+              >
+                learn_more
+              </Link>,
+              "price": "billing.price_format.10",
+            }
+          }
+        />
+      </p>
+    </header>
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
+        Array [
+          Object {
+            "key": "auto",
+            "node": <React.Fragment>
+              onboarding.create_organization.import_organization.github
+              <span
+                className="rounded alert alert-small spacer-left display-inline-block alert-info"
+              >
+                beta
+              </span>
+            </React.Fragment>,
+          },
+          Object {
+            "disabled": false,
+            "key": "manual",
+            "node": "onboarding.create_organization.create_manually",
+          },
+        ]
+      }
+    />
+    <AutoOrganizationCreate
+      almApplication={
+        Object {
+          "backgroundColor": "blue",
+          "iconPath": "icon/path",
+          "installationUrl": "https://alm.installation.url",
+          "key": "github",
+          "name": "GitHub",
+        }
+      }
+      onOrgCreated={[Function]}
+    />
+  </div>
+</Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
new file mode 100644 (file)
index 0000000..be548a1
--- /dev/null
@@ -0,0 +1,74 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render and create organization 1`] = `
+<Fragment>
+  <OrganizationDetailsStep
+    finished={false}
+    onContinue={[Function]}
+    onOpen={[Function]}
+    open={true}
+    submitText="continue"
+  />
+  <PlanStep
+    createOrganization={[Function]}
+    deleteOrganization={[Function]}
+    onFreePlanChoose={[Function]}
+    onPaidPlanChoose={[Function]}
+    open={false}
+    startingPrice="billing.price_format.10"
+    subscriptionPlans={
+      Array [
+        Object {
+          "maxNcloc": 100000,
+          "price": 10,
+        },
+        Object {
+          "maxNcloc": 250000,
+          "price": 75,
+        },
+      ]
+    }
+  />
+</Fragment>
+`;
+
+exports[`should render and create organization 2`] = `
+<Fragment>
+  <OrganizationDetailsStep
+    finished={true}
+    onContinue={[Function]}
+    onOpen={[Function]}
+    open={false}
+    organization={
+      Object {
+        "avatar": "http://example.com/avatar",
+        "description": "description-foo",
+        "key": "key-foo",
+        "name": "name-foo",
+        "url": "http://example.com/foo",
+      }
+    }
+    submitText="continue"
+  />
+  <PlanStep
+    createOrganization={[Function]}
+    deleteOrganization={[Function]}
+    onFreePlanChoose={[Function]}
+    onPaidPlanChoose={[Function]}
+    open={true}
+    startingPrice="billing.price_format.10"
+    subscriptionPlans={
+      Array [
+        Object {
+          "maxNcloc": 100000,
+          "price": 10,
+        },
+        Object {
+          "maxNcloc": 250000,
+          "price": 75,
+        },
+      ]
+    }
+  />
+</Fragment>
+`;
index 4142791c4888b34c794814f1ab52a615046fb3c2..b8bd98adf5b90a4ada96aa9df3ea100f38def7c8 100644 (file)
@@ -5,7 +5,9 @@ exports[`should render 1`] = `
   <label
     htmlFor="field"
   >
-    Label
+    <strong>
+      Label
+    </strong>
     <em
       className="mandatory"
     >
index 76102b3bb2f20aafbf6c4beab46c4018f493fa7d..a52c598379da9842be6765a198c00ea730b1bf75 100644 (file)
@@ -64,6 +64,7 @@ exports[`should render form 3`] = `
     dirty={false}
     id="organization-key"
     isSubmitting={false}
+    isValidating={false}
     label="onboarding.create_organization.organization_name"
     name="key"
     onBlur={[Function]}
@@ -98,6 +99,7 @@ exports[`should render form 3`] = `
         dirty={false}
         id="organization-display-name"
         isSubmitting={false}
+        isValidating={false}
         label="onboarding.create_organization.display_name"
         name="name"
         onBlur={[Function]}
@@ -115,6 +117,7 @@ exports[`should render form 3`] = `
         dirty={false}
         id="organization-avatar"
         isSubmitting={false}
+        isValidating={false}
         label="onboarding.create_organization.avatar"
         name="avatar"
         onBlur={[Function]}
@@ -131,6 +134,7 @@ exports[`should render form 3`] = `
         dirty={false}
         id="organization-description"
         isSubmitting={false}
+        isValidating={false}
         label="description"
         name="description"
         onBlur={[Function]}
@@ -147,6 +151,7 @@ exports[`should render form 3`] = `
         dirty={false}
         id="organization-url"
         isSubmitting={false}
+        isValidating={false}
         label="onboarding.create_organization.url"
         name="url"
         onBlur={[Function]}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx
new file mode 100644 (file)
index 0000000..07d1c6f
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { formatPrice } from '../utils';
+
+jest.mock('../../../../helpers/urls', () => ({
+  getHostUrl: () => 'http://host.url'
+}));
+
+describe('#formatPrice', () => {
+  it('formats correctly', () => {
+    expect(formatPrice(10)).toBe('billing.price_format.10');
+    expect(formatPrice(10000)).toBe('billing.price_format.10,000');
+    expect(formatPrice(10000, true)).toBe('10,000');
+  });
+});
index 29fc906c0d7af51549be5a686c8a36c1baf074bb..bfe1825632ae7859cbba4116a454baa699b04a52 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 { memoize } from 'lodash';
 import { translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure } from '../../../helpers/measures';
+import { RawQuery, parseAsOptionalString } from '../../../helpers/query';
 
 export function formatPrice(price?: number, noSign?: boolean) {
   const priceFormatted = formatMeasure(price, 'FLOAT')
@@ -26,3 +28,17 @@ export function formatPrice(price?: number, noSign?: boolean) {
     .replace(/([.|,]\d)$/, '$10');
   return noSign ? priceFormatted : translateWithParameters('billing.price_format', priceFormatted);
 }
+
+export interface Query {
+  almInstallId?: string;
+}
+
+export const parseQuery = memoize(
+  (urlQuery: RawQuery = {}): Query => {
+    return {
+      almInstallId:
+        parseAsOptionalString(urlQuery['installation_id']) ||
+        parseAsOptionalString(urlQuery['clientKey'])
+    };
+  }
+);
index ae6e431535d0af74a3c1e39928a4a2962fdd2f80..be69f2c4361f2300ea363138a95acd93b47cafa6 100644 (file)
@@ -20,7 +20,8 @@
 import * as React from 'react';
 import { withRouter, WithRouterProps } from 'react-router';
 import { withCurrentUser } from './withCurrentUser';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 
 export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) {
   const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
index ed8f05fec887d2ac842ead3ce1bc96daaaa19f7c..2025d4bb7f193b726f9c278579246805143719b0 100644 (file)
 import * as React from 'react';
 import { connect } from 'react-redux';
 import AppContainer from './components/AppContainer';
-import { CurrentUser, isLoggedIn } from '../../app/types';
+import { CurrentUser } from '../../app/types';
 import { RawQuery } from '../../helpers/query';
 import { getCurrentUser, Store } from '../../store/rootReducer';
 import { isSonarCloud } from '../../helpers/system';
+import { isLoggedIn } from '../../helpers/users';
 
 interface StateProps {
   currentUser: CurrentUser;
index e06f3a4fed1450ebfad5678b1f001a1360652f55..6df59fe2ccba4e0b97b1e58266243b854ccdd0c8 100644 (file)
@@ -21,7 +21,7 @@ import * as React from 'react';
 import { pickBy, sortBy } from 'lodash';
 import { searchAssignees } from '../utils';
 import { searchIssueTags, bulkChangeIssues } from '../../../api/issues';
-import { Component, CurrentUser, Issue, Paging, isLoggedIn, IssueType } from '../../../app/types';
+import { Component, CurrentUser, Issue, Paging, IssueType } from '../../../app/types';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
 import MarkdownTips from '../../../components/common/MarkdownTips';
 import SearchSelect from '../../../components/controls/SearchSelect';
@@ -35,6 +35,7 @@ import { SubmitButton } from '../../../components/ui/buttons';
 import IssueTypeIcon from '../../../components/ui/IssueTypeIcon';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Alert } from '../../../components/ui/Alert';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface AssigneeOption {
   avatar?: string;
index e1b30839a3958f2c406827c89633a3834464c3d6..1689dc068961b0fee649d8d6134306a3cdbb3ddb 100644 (file)
@@ -22,7 +22,8 @@ import { connect } from 'react-redux';
 import { RouterState } from 'react-router';
 import { getCurrentUser, getOrganizationByKey, Store } from '../../../store/rootReducer';
 import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization';
-import { Organization, CurrentUser, isLoggedIn } from '../../../app/types';
+import { Organization, CurrentUser } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface StateToProps {
   currentUser: CurrentUser;
index bc0bca0a822a6ce8f213eced661d50097ec3d43d..d376085573a44303ed562bae56f2d8ad5e5d2426 100644 (file)
@@ -24,6 +24,8 @@ import OrganizationAvatar from '../../../components/common/OrganizationAvatar';
 import Dropdown from '../../../components/controls/Dropdown';
 import DropdownIcon from '../../../components/icons-components/DropdownIcon';
 import OrganizationListItem from '../../../components/ui/OrganizationListItem';
+import { sanitizeAlmId } from '../../../helpers/almIntegrations';
+import { getBaseUrl } from '../../../helpers/urls';
 
 interface Props {
   organization: Organization;
@@ -56,6 +58,21 @@ export default function OrganizationNavigationHeader({ organization, organizatio
       ) : (
         <span className="spacer-left">{organization.name}</span>
       )}
+      {organization.almRepoUrl && (
+        <a
+          className="link-no-underline"
+          href={organization.almRepoUrl}
+          rel="noopener noreferrer"
+          target="_blank">
+          <img
+            alt={sanitizeAlmId(organization.almId)}
+            className="text-text-top spacer-left"
+            height={16}
+            src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(organization.almId)}.svg`}
+            width={16}
+          />
+        </a>
+      )}
       {organization.description != null && (
         <div className="navbar-context-description">
           <p className="text-limited text-top" title={organization.description}>
index 57d43dc4682fc635dc73ff5f2ae7ca459b34a2e0..021b80766e405e68b001cd6003cbaa068363f931 100644 (file)
@@ -23,10 +23,23 @@ import OrganizationNavigationHeader from '../OrganizationNavigationHeader';
 import { Visibility } from '../../../../app/types';
 
 it('renders', () => {
+  expect(
+    shallow(
+      <OrganizationNavigationHeader
+        organization={{ key: 'foo', name: 'Foo', projectVisibility: Visibility.Public }}
+        organizations={[]}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('renders with alm integration', () => {
   expect(
     shallow(
       <OrganizationNavigationHeader
         organization={{
+          almId: 'github',
+          almRepoUrl: 'https://github.com/foo',
           key: 'foo',
           name: 'Foo',
           projectVisibility: Visibility.Public
index 82dd13c2a2aabbf55e12a25ea7c7abdb197ec7d6..cf3e383e57384de3444bf1081f1d7089444ff2c1 100644 (file)
@@ -62,3 +62,40 @@ exports[`renders dropdown 1`] = `
   </a>
 </Dropdown>
 `;
+
+exports[`renders with alm integration 1`] = `
+<header
+  className="navbar-context-header"
+>
+  <OrganizationAvatar
+    organization={
+      Object {
+        "almId": "github",
+        "almRepoUrl": "https://github.com/foo",
+        "key": "foo",
+        "name": "Foo",
+        "projectVisibility": "public",
+      }
+    }
+  />
+  <span
+    className="spacer-left"
+  >
+    Foo
+  </span>
+  <a
+    className="link-no-underline"
+    href="https://github.com/foo"
+    rel="noopener noreferrer"
+    target="_blank"
+  >
+    <img
+      alt="github"
+      className="text-text-top spacer-left"
+      height={16}
+      src="/images/sonarcloud/github.svg"
+      width={16}
+    />
+  </a>
+</header>
+`;
index c72610e8991ca04a91b3a05d30cef050a9ae0ec2..46ebf59c072396cf530e8d6122c7d67277588f4a 100644 (file)
@@ -22,9 +22,10 @@ import { connect } from 'react-redux';
 import { FormattedMessage } from 'react-intl';
 import AnalyzeTutorial from '../../tutorials/analyzeProject/AnalyzeTutorial';
 import MetaContainer from '../meta/MetaContainer';
-import { BranchLike, Component, CurrentUser, isLoggedIn } from '../../../app/types';
+import { BranchLike, Component, CurrentUser } from '../../../app/types';
 import { isLongLivingBranch, isBranch, isMainBranch } from '../../../helpers/branches';
 import { translate } from '../../../helpers/l10n';
+import { isLoggedIn } from '../../../helpers/users';
 import { getCurrentUser, Store } from '../../../store/rootReducer';
 import '../../../app/styles/sonarcloud.css';
 import { Alert } from '../../../components/ui/Alert';
index 79558f42221eb4441756d82f0c9d27d24b4f1428..4d55cc25215d11910e5609e97ca88a20cf0c614a 100644 (file)
@@ -22,7 +22,8 @@ import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessI
 import { ReportStatus, subscribe, unsubscribe } from '../../../api/report';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Button } from '../../../components/ui/buttons';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   component: string;
index cb7a50672a7b61c24c8dd9d6d3a4772920693d34..0e1c185aaac4549d808fc18bc545823e0d71f03f 100644 (file)
@@ -26,10 +26,11 @@ import ProjectsList from './ProjectsList';
 import PageSidebar from './PageSidebar';
 import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
 import Visualizations from '../visualizations/Visualizations';
-import { CurrentUser, isLoggedIn, Organization } from '../../../app/types';
+import { CurrentUser, Organization } from '../../../app/types';
 import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
-import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import ListFooter from '../../../components/controls/ListFooter';
+import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
 import { translate } from '../../../helpers/l10n';
 import { get, save } from '../../../helpers/storage';
 import { RawQuery } from '../../../helpers/query';
@@ -37,9 +38,9 @@ import { Project, Facets } from '../types';
 import { fetchProjects, parseSorting, SORTING_SWITCH } from '../utils';
 import { parseUrlQuery, Query, hasFilterParams, hasVisualizationParams } from '../query';
 import { isSonarCloud } from '../../../helpers/system';
+import { isLoggedIn } from '../../../helpers/users';
 import '../../../components/search-navigator.css';
 import '../styles.css';
-import DeferredSpinner from '../../../components/common/DeferredSpinner';
 
 export interface Props {
   currentUser: CurrentUser;
index 8b4e37b1ec628e40e64ccebdca03dc17b61ddf38..c2d8c70efdd3f360c4ff8800bc1be06b8bd99e61 100644 (file)
@@ -23,8 +23,9 @@ import AllProjectsContainer from './AllProjectsContainer';
 import { PROJECTS_DEFAULT_FILTER, PROJECTS_FAVORITE, PROJECTS_ALL } from '../utils';
 import { get } from '../../../helpers/storage';
 import { searchProjects } from '../../../api/components';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
 import { isSonarCloud } from '../../../helpers/system';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   currentUser: CurrentUser;
index 5bbcfe9d14bc5c98ba04b72daa3e7b7135c9330e..42fa6f2f1160d78b104bdb768f363c9a0cc213c4 100644 (file)
@@ -21,8 +21,9 @@ import * as React from 'react';
 import * as PropTypes from 'prop-types';
 import { translate } from '../../../helpers/l10n';
 import { Button } from '../../../components/ui/buttons';
-import { Organization, CurrentUser, isLoggedIn, hasGlobalPermission } from '../../../app/types';
+import { Organization, CurrentUser } from '../../../app/types';
 import { isSonarCloud } from '../../../helpers/system';
+import { hasGlobalPermission, isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   organization?: Organization;
index 3b9a46cbb7b531d003c258b88d8c8382eb9681df..ebec0b9f090f4d8600dce498b53562b61975097b 100644 (file)
 import * as React from 'react';
 import { IndexLink, Link } from 'react-router';
 import { translate } from '../../../helpers/l10n';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
 import { save } from '../../../helpers/storage';
 import { RawQuery } from '../../../helpers/query';
 import { PROJECTS_DEFAULT_FILTER, PROJECTS_FAVORITE, PROJECTS_ALL } from '../utils';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   currentUser: CurrentUser;
index c29cc32f56c619741ba480d3875e3c20a1677142..54cae091589373d117f7e9e56ea9c3a7b32a687f 100644 (file)
@@ -23,12 +23,13 @@ import PerspectiveSelect from './PerspectiveSelect';
 import ProjectsSortingSelect from './ProjectsSortingSelect';
 import SearchFilterContainer from '../filters/SearchFilterContainer';
 import Tooltip from '../../../components/controls/Tooltip';
-import { CurrentUser, isLoggedIn, HomePageType } from '../../../app/types';
+import { CurrentUser, HomePageType } from '../../../app/types';
 import HomePageSelect from '../../../components/controls/HomePageSelect';
 import { translate } from '../../../helpers/l10n';
 import { RawQuery } from '../../../helpers/query';
 import { Project } from '../types';
 import { isSonarCloud } from '../../../helpers/system';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface Props {
   currentUser: CurrentUser;
index 1b1840e1381e9e81fb0350d58b1788964474f73c..f45dccd05d7b09212177e4719a8011d1bc57c575 100644 (file)
@@ -27,14 +27,17 @@ import AutoProjectCreate from './AutoProjectCreate';
 import ManualProjectCreate from './ManualProjectCreate';
 import { serializeQuery, Query, parseQuery } from './utils';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import Tabs from '../../../components/controls/Tabs';
 import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
 import { getCurrentUser, Store } from '../../../store/rootReducer';
 import { addGlobalErrorMessage } from '../../../store/globalMessages';
 import { skipOnboarding as skipOnboardingAction } from '../../../store/users';
-import { CurrentUser, IdentityProvider, isLoggedIn, LoggedInUser } from '../../../app/types';
+import { CurrentUser, IdentityProvider, LoggedInUser } from '../../../app/types';
 import { skipOnboarding, getIdentityProviders } from '../../../api/users';
+import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
 import { getProjectUrl } from '../../../helpers/urls';
+import { isLoggedIn } from '../../../helpers/users';
 import '../../../app/styles/sonarcloud.css';
 
 interface OwnProps {
@@ -69,7 +72,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
       if (query.error) {
         this.props.addGlobalErrorMessage(query.error);
       }
-      if (!this.canAutoCreate()) {
+      if (!hasAdvancedALMIntegration(this.props.currentUser)) {
         this.setState({ loading: false });
         this.updateQuery({ manual: true });
       } else {
@@ -102,10 +105,6 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
     }
   };
 
-  canAutoCreate = ({ currentUser } = this.props) => {
-    return ['bitbucket', 'github'].includes((currentUser as LoggedInUser).externalProvider || '');
-  };
-
   fetchIdentityProviders = () => {
     getIdentityProviders().then(
       ({ identityProviders }) => {
@@ -127,14 +126,8 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
     );
   };
 
-  showAuto = (event: React.MouseEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    this.updateQuery({ manual: false });
-  };
-
-  showManual = (event: React.MouseEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    this.updateQuery({ manual: true });
+  onTabChange = (tab: 'auto' | 'manual') => {
+    this.updateQuery({ manual: tab === 'manual' });
   };
 
   updateQuery = (changes: Partial<Query>) => {
@@ -152,49 +145,45 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
     }
 
     const { identityProvider, loading } = this.state;
-    const displayManual = parseQuery(this.props.location.query).manual;
-    const header = translate('onboarding.create_project.header');
-    const hasAutoProvisioning = this.canAutoCreate() && identityProvider;
     const query = parseQuery(this.props.location.query);
+    const header = translate('onboarding.create_project.header');
+    const hasAutoProvisioning = hasAdvancedALMIntegration(currentUser) && identityProvider;
     return (
       <>
         <Helmet title={header} titleTemplate="%s" />
         <div className="sonarcloud page page-limited">
-          <div className="page-header">
+          <header className="page-header">
             <h1 className="page-title">{header}</h1>
-          </div>
+          </header>
           {loading ? (
             <DeferredSpinner />
           ) : (
             <>
               {hasAutoProvisioning && (
-                <ul className="flex-tabs">
-                  <li>
-                    <a
-                      className={classNames('js-auto', { selected: !displayManual })}
-                      href="#"
-                      onClick={this.showAuto}>
-                      {translate('onboarding.create_project.select_repositories')}
-                      <div
-                        className={classNames('beta-badge spacer-left', {
-                          'is-muted': displayManual
-                        })}>
-                        {translate('beta')}
-                      </div>
-                    </a>
-                  </li>
-                  <li>
-                    <a
-                      className={classNames('js-manual', { selected: displayManual })}
-                      href="#"
-                      onClick={this.showManual}>
-                      {translate('onboarding.create_project.create_manually')}
-                    </a>
-                  </li>
-                </ul>
+                <Tabs
+                  onChange={this.onTabChange}
+                  selected={query.manual ? 'manual' : 'auto'}
+                  tabs={[
+                    {
+                      key: 'auto',
+                      node: (
+                        <>
+                          {translate('onboarding.create_project.select_repositories')}
+                          <span
+                            className={classNames('beta-badge spacer-left', {
+                              'is-muted': query.manual
+                            })}>
+                            {translate('beta')}
+                          </span>
+                        </>
+                      )
+                    },
+                    { key: 'manual', node: translate('onboarding.create_project.create_manually') }
+                  ]}
+                />
               )}
 
-              {displayManual || !hasAutoProvisioning || !identityProvider ? (
+              {query.manual || !hasAutoProvisioning || !identityProvider ? (
                 <ManualProjectCreate
                   currentUser={currentUser}
                   onProjectCreate={this.handleProjectCreate}
index d20c0fd9ac841e1b4010d3ebe169c5434cea31eb..fba7c5875c7af7c30e7ffb000b8359094a71500a 100644 (file)
@@ -23,7 +23,7 @@ import { Location } from 'history';
 import { CreateProjectPage } from '../CreateProjectPage';
 import { getIdentityProviders } from '../../../../api/users';
 import { LoggedInUser } from '../../../../app/types';
-import { click, waitAndUpdate } from '../../../../helpers/testUtils';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
 
 jest.mock('../../../../api/users', () => ({
   getIdentityProviders: jest.fn().mockResolvedValue({
@@ -70,12 +70,11 @@ it('should switch tabs', async () => {
   });
 
   await waitAndUpdate(wrapper);
-
   expect(wrapper).toMatchSnapshot();
 
-  click(wrapper.find('.js-manual'));
+  wrapper.find('Tabs').prop<Function>('onChange')('manual');
   expect(wrapper.find('Connect(ManualProjectCreate)').exists()).toBeTruthy();
-  click(wrapper.find('.js-auto'));
+  wrapper.find('Tabs').prop<Function>('onChange')('auto');
   expect(wrapper.find('AutoProjectCreate').exists()).toBeTruthy();
 });
 
index a1eaa7bbd49d97dbafb996716713123d2df61ad2..69731b0ff8e838760149614822f2329bed111a77 100644 (file)
@@ -11,7 +11,7 @@ exports[`should render correctly 1`] = `
   <div
     className="sonarcloud page page-limited"
   >
-    <div
+    <header
       className="page-header"
     >
       <h1
@@ -19,7 +19,7 @@ exports[`should render correctly 1`] = `
       >
         onboarding.create_project.header
       </h1>
-    </div>
+    </header>
     <DeferredSpinner
       timeout={100}
     />
@@ -38,7 +38,7 @@ exports[`should render correctly 2`] = `
   <div
     className="sonarcloud page page-limited"
   >
-    <div
+    <header
       className="page-header"
     >
       <h1
@@ -46,34 +46,30 @@ exports[`should render correctly 2`] = `
       >
         onboarding.create_project.header
       </h1>
-    </div>
-    <ul
-      className="flex-tabs"
-    >
-      <li>
-        <a
-          className="js-auto selected"
-          href="#"
-          onClick={[Function]}
-        >
-          onboarding.create_project.select_repositories
-          <div
-            className="beta-badge spacer-left"
-          >
-            beta
-          </div>
-        </a>
-      </li>
-      <li>
-        <a
-          className="js-manual"
-          href="#"
-          onClick={[Function]}
-        >
-          onboarding.create_project.create_manually
-        </a>
-      </li>
-    </ul>
+    </header>
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
+        Array [
+          Object {
+            "key": "auto",
+            "node": <React.Fragment>
+              onboarding.create_project.select_repositories
+              <span
+                className="beta-badge spacer-left"
+              >
+                beta
+              </span>
+            </React.Fragment>,
+          },
+          Object {
+            "key": "manual",
+            "node": "onboarding.create_project.create_manually",
+          },
+        ]
+      }
+    />
     <AutoProjectCreate
       identityProvider={
         Object {
@@ -100,7 +96,7 @@ exports[`should render with Manual creation only 1`] = `
   <div
     className="sonarcloud page page-limited"
   >
-    <div
+    <header
       className="page-header"
     >
       <h1
@@ -108,7 +104,7 @@ exports[`should render with Manual creation only 1`] = `
       >
         onboarding.create_project.header
       </h1>
-    </div>
+    </header>
     <Connect(ManualProjectCreate)
       currentUser={
         Object {
@@ -137,7 +133,7 @@ exports[`should switch tabs 1`] = `
   <div
     className="sonarcloud page page-limited"
   >
-    <div
+    <header
       className="page-header"
     >
       <h1
@@ -145,34 +141,30 @@ exports[`should switch tabs 1`] = `
       >
         onboarding.create_project.header
       </h1>
-    </div>
-    <ul
-      className="flex-tabs"
-    >
-      <li>
-        <a
-          className="js-auto selected"
-          href="#"
-          onClick={[Function]}
-        >
-          onboarding.create_project.select_repositories
-          <div
-            className="beta-badge spacer-left"
-          >
-            beta
-          </div>
-        </a>
-      </li>
-      <li>
-        <a
-          className="js-manual"
-          href="#"
-          onClick={[Function]}
-        >
-          onboarding.create_project.create_manually
-        </a>
-      </li>
-    </ul>
+    </header>
+    <Tabs
+      onChange={[Function]}
+      selected="auto"
+      tabs={
+        Array [
+          Object {
+            "key": "auto",
+            "node": <React.Fragment>
+              onboarding.create_project.select_repositories
+              <span
+                className="beta-badge spacer-left"
+              >
+                beta
+              </span>
+            </React.Fragment>,
+          },
+          Object {
+            "key": "manual",
+            "node": "onboarding.create_project.create_manually",
+          },
+        ]
+      }
+    />
     <AutoProjectCreate
       identityProvider={
         Object {
index b41edb8e99e22ae5b1b260f54a7d0c81821605be..60a134428aaff079557075e873b4a7b544ab6bab 100644 (file)
@@ -26,8 +26,9 @@ import OnboardingProjectIcon from '../../../components/icons-components/Onboardi
 import OnboardingTeamIcon from '../../../components/icons-components/OnboardingTeamIcon';
 import { Button, ResetButtonLink } from '../../../components/ui/buttons';
 import { translate } from '../../../helpers/l10n';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
 import { getCurrentUser, Store } from '../../../store/rootReducer';
+import { isLoggedIn } from '../../../helpers/users';
 import '../styles.css';
 
 interface OwnProps {
index 9068fb81f94a98f511ffe6f3ec0386dc85d54e33..164f58ed4674a0655075610360766eedca05a0a7 100644 (file)
@@ -27,11 +27,12 @@ import OrganizationStep from '../components/OrganizationStep';
 import TokenStep from '../components/TokenStep';
 import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
 import { getCurrentUser, areThereCustomOrganizations, Store } from '../../../store/rootReducer';
-import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { CurrentUser } from '../../../app/types';
 import { ResetButtonLink } from '../../../components/ui/buttons';
 import { getProjectUrl } from '../../../helpers/urls';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { isSonarCloud } from '../../../helpers/system';
+import { isLoggedIn } from '../../../helpers/users';
 import '../styles.css';
 
 interface OwnProps {
index 5be1bbc5816724ac1c3fb7aa7505a4a29a697d32..fef52ae89028ce8ee07ba92f2ba2bbcabe4b1962 100644 (file)
@@ -22,10 +22,11 @@ import * as classNames from 'classnames';
 import { connect } from 'react-redux';
 import Tooltip from './Tooltip';
 import HomeIcon from '../icons-components/HomeIcon';
-import { CurrentUser, isLoggedIn, HomePage, isSameHomePage } from '../../app/types';
+import { CurrentUser, HomePage, isSameHomePage } from '../../app/types';
 import { translate } from '../../helpers/l10n';
 import { getCurrentUser, Store } from '../../store/rootReducer';
 import { setHomePage } from '../../store/users';
+import { isLoggedIn } from '../../helpers/users';
 
 interface StateProps {
   currentUser: CurrentUser;
diff --git a/server/sonar-web/src/main/js/components/controls/Tabs.css b/server/sonar-web/src/main/js/components/controls/Tabs.css
new file mode 100644 (file)
index 0000000..45d8d64
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+.flex-tabs {
+  display: flex;
+  clear: left;
+  margin-bottom: calc(3 * var(--gridSize));
+  border-bottom: 1px solid var(--barBorderColor);
+  font-size: var(--mediumFontSize);
+}
+
+.flex-tabs > li > a {
+  position: relative;
+  display: block;
+  top: 1px;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  color: var(--secondFontColor);
+  font-weight: 600;
+  cursor: pointer;
+  padding-bottom: calc(1.5 * var(--gridSize));
+  border-bottom: 3px solid transparent;
+  transition: color 0.2s ease;
+}
+
+.flex-tabs > li ~ li {
+  margin-left: calc(4 * var(--gridSize));
+}
+
+.flex-tabs > li > a:hover {
+  color: var(--baseFontColor);
+}
+
+.flex-tabs > li > a.selected {
+  color: var(--blue);
+  border-bottom-color: var(--blue);
+}
+
+.flex-tabs > li > a.disabled {
+  color: var(--disableGrayText) !important;
+  cursor: not-allowed !important;
+  pointer-events: none !important;
+}
diff --git a/server/sonar-web/src/main/js/components/controls/Tabs.tsx b/server/sonar-web/src/main/js/components/controls/Tabs.tsx
new file mode 100644 (file)
index 0000000..7678b54
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as classNames from 'classnames';
+import './Tabs.css';
+
+interface Props {
+  onChange: (tab: string) => void;
+  selected?: string;
+  tabs: Array<{ disabled?: boolean; key: string; node: React.ReactNode }>;
+}
+
+export default function Tabs({ onChange, selected, tabs }: Props) {
+  return (
+    <ul className="flex-tabs">
+      {tabs.map(tab => (
+        <Tab
+          disabled={tab.disabled}
+          key={tab.key}
+          name={tab.key}
+          onSelect={onChange}
+          selected={selected === tab.key}>
+          {tab.node}
+        </Tab>
+      ))}
+    </ul>
+  );
+}
+
+interface TabProps {
+  children: React.ReactNode;
+  disabled?: boolean;
+  name: string;
+  onSelect: (tab: string) => void;
+  selected: boolean;
+}
+
+export class Tab extends React.PureComponent<TabProps> {
+  handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.stopPropagation();
+    if (!this.props.disabled) {
+      this.props.onSelect(this.props.name);
+    }
+  };
+
+  render() {
+    const { children, disabled, name, selected } = this.props;
+    return (
+      <li>
+        <a
+          className={classNames('js-' + name, { disabled, selected })}
+          href="#"
+          onClick={this.handleClick}>
+          {children}
+        </a>
+      </li>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx
new file mode 100644 (file)
index 0000000..72441e0
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import Tabs, { Tab } from '../Tabs';
+import { click } from '../../../helpers/testUtils';
+
+it('should render correctly', () => {
+  const wrapper = shallow(
+    <Tabs
+      onChange={jest.fn()}
+      selected={'bar'}
+      tabs={[{ key: 'foo', node: 'Foo' }, { key: 'bar', node: 'Bar' }]}
+    />
+  );
+
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should switch tabs', () => {
+  const onChange = jest.fn();
+  const wrapper = shallow(
+    <Tabs
+      onChange={onChange}
+      selected={'bar'}
+      tabs={[{ key: 'foo', node: 'Foo' }, { key: 'bar', node: 'Bar' }]}
+    />
+  );
+
+  click(shallow(wrapper.find('Tab').get(0)).find('.js-foo'));
+  expect(onChange).toBeCalledWith('foo');
+  click(shallow(wrapper.find('Tab').get(1)).find('.js-bar'));
+  expect(onChange).toBeCalledWith('bar');
+});
+
+it('should render single tab correctly', () => {
+  const onSelect = jest.fn();
+  const wrapper = shallow(
+    <Tab name="foo" onSelect={onSelect} selected={true}>
+      <span>Foo</span>
+    </Tab>
+  );
+  expect(wrapper).toMatchSnapshot();
+  click(wrapper.find('a'));
+  expect(onSelect).toBeCalledWith('foo');
+});
+
+it('should disable single tab', () => {
+  const onSelect = jest.fn();
+  const wrapper = shallow(
+    <Tab disabled={true} name="foo" onSelect={onSelect} selected={true}>
+      <span>Foo</span>
+    </Tab>
+  );
+  expect(wrapper).toMatchSnapshot();
+  click(wrapper.find('a'));
+  expect(onSelect).not.toBeCalled();
+});
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap
new file mode 100644 (file)
index 0000000..2db4cec
--- /dev/null
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should disable single tab 1`] = `
+<li>
+  <a
+    className="js-foo disabled selected"
+    href="#"
+    onClick={[Function]}
+  >
+    <span>
+      Foo
+    </span>
+  </a>
+</li>
+`;
+
+exports[`should render correctly 1`] = `
+<ul
+  className="flex-tabs"
+>
+  <Tab
+    key="foo"
+    name="foo"
+    onSelect={[MockFunction]}
+    selected={false}
+  >
+    Foo
+  </Tab>
+  <Tab
+    key="bar"
+    name="bar"
+    onSelect={[MockFunction]}
+    selected={true}
+  >
+    Bar
+  </Tab>
+</ul>
+`;
+
+exports[`should render single tab correctly 1`] = `
+<li>
+  <a
+    className="js-foo selected"
+    href="#"
+    onClick={[Function]}
+  >
+    <span>
+      Foo
+    </span>
+  </a>
+</li>
+`;
index 28bd4a38b988acdd49cea48260e4981440b54975..6d4e9744ebde56e02be52de6e143fbe487cb55ea 100644 (file)
@@ -29,8 +29,9 @@ import { searchUsers } from '../../../api/users';
 import { translate } from '../../../helpers/l10n';
 import { getCurrentUser, Store } from '../../../store/rootReducer';
 import { DropdownOverlay } from '../../controls/Dropdown';
-import { Issue, CurrentUser, isLoggedIn, OrganizationMember } from '../../../app/types';
+import { Issue, CurrentUser, OrganizationMember } from '../../../app/types';
 import { isSonarCloud } from '../../../helpers/system';
+import { isLoggedIn } from '../../../helpers/users';
 
 interface User {
   avatar?: string;
index de03cb6d31500e2aadb3b3a5850b998b42e01ecd..c943f67b90e5e4c8c4d1b03fc5f1adba0a628579 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 { isLoggedIn } from './users';
+import { CurrentUser } from '../app/types';
+
+export function hasAdvancedALMIntegration(user: CurrentUser) {
+  return (
+    isLoggedIn(user) && (isBitbucket(user.externalProvider) || isGithub(user.externalProvider))
+  );
+}
 
 export function isBitbucket(almId?: string) {
   return almId && almId.startsWith('bitbucket');
index 97a57c142389325ee2a02a59898c544e2eff6588..2c0cdc4f3735dbd9737c1ae07a93041a62d93bac 100644 (file)
@@ -17,7 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Organization, isLoggedIn, OrganizationSubscription, CurrentUser } from '../app/types';
+import { isLoggedIn } from './users';
+import { Organization, OrganizationSubscription, CurrentUser } from '../app/types';
 
 export function isPaidOrganization(organization: Organization | undefined): boolean {
   return Boolean(organization && organization.subscription === OrganizationSubscription.Paid);
diff --git a/server/sonar-web/src/main/js/helpers/users.ts b/server/sonar-web/src/main/js/helpers/users.ts
new file mode 100644 (file)
index 0000000..77968f0
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { CurrentUser, LoggedInUser } from '../app/types';
+
+export function hasGlobalPermission(user: CurrentUser, permission: string): boolean {
+  if (!user.permissions) {
+    return false;
+  }
+  return user.permissions.global.includes(permission);
+}
+
+export function isLoggedIn(user: CurrentUser): user is LoggedInUser {
+  return user.isLoggedIn;
+}
index 98df5df543f82637291c9df2feefadb6ede03c99..8440dab6e2eb138909f212d88d38fa0d195ff6bd 100644 (file)
@@ -21,16 +21,24 @@ import { uniq } from 'lodash';
 import { Dispatch, combineReducers } from 'redux';
 import { ActionType } from './utils/actions';
 import * as api from '../api/users';
-import { CurrentUser, HomePage, isLoggedIn, LoggedInUser } from '../app/types';
+import { CurrentUser, HomePage, LoggedInUser } from '../app/types';
+import { isLoggedIn } from '../helpers/users';
 
 export function receiveCurrentUser(user: CurrentUser) {
   return { type: 'RECEIVE_CURRENT_USER', user };
 }
 
-export function skipOnboarding() {
+function skipOnboardingAction() {
   return { type: 'SKIP_ONBOARDING' };
 }
 
+export function skipOnboarding() {
+  return (dispatch: Dispatch) =>
+    api
+      .skipOnboarding()
+      .then(() => dispatch(skipOnboardingAction()), () => dispatch(skipOnboardingAction()));
+}
+
 function setHomePageAction(homepage: HomePage) {
   return { type: 'SET_HOMEPAGE', homepage };
 }
@@ -48,7 +56,7 @@ export function setHomePage(homepage: HomePage) {
 
 type Action =
   | ActionType<typeof receiveCurrentUser, 'RECEIVE_CURRENT_USER'>
-  | ActionType<typeof skipOnboarding, 'SKIP_ONBOARDING'>
+  | ActionType<typeof skipOnboardingAction, 'SKIP_ONBOARDING'>
   | ActionType<typeof setHomePageAction, 'SET_HOMEPAGE'>;
 
 export interface State {
index 1c621e846718349586e604c890ef08a33f499bfc..e098be7449eaa4ee194cae2265422c10732093f6 100644 (file)
@@ -2730,7 +2730,7 @@ onboarding.create_project.select_repositories=Select repositories
 
 onboarding.create_organization.page.header=Create Organization
 onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects.{break}To analyze a private project you must subscribe your organization to a paid plan. From {price} a month. {more}
-onboarding.create_organization.organization_name=Organization Name
+onboarding.create_organization.organization_name=Key
 onboarding.create_organization.organization_name.description=Up to 255 characters. All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). The display name can be specified in the additional info.
 onboarding.create_organization.organization_name.error=The provided value doesn't match the expected format.
 onboarding.create_organization.organization_name.taken=This name is already taken.
@@ -2746,6 +2746,16 @@ onboarding.create_organization.url=URL
 onboarding.create_organization.url.error=The value must be a valid url.
 onboarding.create_organization.description=Description
 onboarding.create_organization.enter_org_details=Enter your organization details
+onboarding.create_organization.create_manually=Create manually
+onboarding.create_organization.import_organization.bitbucket=Import from BitBucket teams
+onboarding.create_organization.import_organization.github=Import from GitHub organizations
+onboarding.create_organization.import_organization_x=Import {avatar} {name} into SonarCloud organization
+onboarding.create_organization.import_org_details=Import organization details
+onboarding.create_organization.import_org_not_found=We were not able to find the requested organization, here are a few tips to help you troubleshoot the issue:
+onboarding.create_organization.import_org_not_found.tips_1=You must be an administrator of the organization
+onboarding.create_organization.import_org_not_found.tips_2=Try to uninstall and re-install the SonarCloud App (using the button bellow)
+onboarding.create_organization.choose_organization_button.bitbucket=Choose a team on Bitbucket
+onboarding.create_organization.choose_organization_button.github=Choose an organization on GitHub
 onboarding.create_organization.enter_payment_details=Enter payment details
 onboarding.create_organization.choose_plan=Choose a plan
 onboarding.create_organization.enter_your_coupon=Enter your coupon