diff options
78 files changed, 1892 insertions, 418 deletions
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java index 03361e5b586..04302fe6f06 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java @@ -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(); } diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java index 809a054ca26..cb979c2217f 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java @@ -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, diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml index 2454efde08c..d96a33dfe13 100644 --- a/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml @@ -22,6 +22,16 @@ 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 diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java index 5a15a5a04ab..00955915f03 100644 --- a/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java +++ b/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java @@ -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(); @@ -95,6 +94,16 @@ public class AlmAppInstallDaoTest { } @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) diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java b/server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java index 190bdecd3ed..8a51b94e839 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java +++ b/server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java @@ -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 { diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java b/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java index 7252ea912e1..9f898109d57 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java +++ b/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java @@ -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); + } } /** diff --git a/server/sonar-web/src/main/js/api/alm-integration.ts b/server/sonar-web/src/main/js/api/alm-integration.ts index d35ff9995e0..c3f560044f2 100644 --- a/server/sonar-web/src/main/js/api/alm-integration.ts +++ b/server/sonar-web/src/main/js/api/alm-integration.ts @@ -18,9 +18,23 @@ * 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; diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts index 289d6ce99d5..1b72037ce83 100644 --- a/server/sonar-web/src/main/js/api/organizations.ts +++ b/server/sonar-web/src/main/js/api/organizations.ts @@ -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); } diff --git a/server/sonar-web/src/main/js/app/components/Landing.tsx b/server/sonar-web/src/main/js/app/components/Landing.tsx index f96be5fc011..421c6cceb7c 100644 --- a/server/sonar-web/src/main/js/app/components/Landing.tsx +++ b/server/sonar-web/src/main/js/app/components/Landing.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx index a5f0263139a..c0393198c15 100644 --- a/server/sonar-web/src/main/js/app/components/StartupModal.tsx +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -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( diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx index d6f38f12a29..02d69091357 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx @@ -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 { diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx index 0eda00d3b95..67cda337eae 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx @@ -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'); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx index 8856840d3f1..6ff59a0854d 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx @@ -20,13 +20,14 @@ 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'>; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx index 99d30116560..4e3f45d648b 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx @@ -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'>; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx index 8d11216b9f7..530f17d248f 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx @@ -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 }; diff --git a/server/sonar-web/src/main/js/app/styles/components/menu.css b/server/sonar-web/src/main/js/app/styles/components/menu.css index 9bead9c66d3..b11b91e4591 100644 --- a/server/sonar-web/src/main/js/app/styles/components/menu.css +++ b/server/sonar-web/src/main/js/app/styles/components/menu.css @@ -72,7 +72,7 @@ } .menu > li > a.disabled { - color: #bbb !important; + color: var(--disableGrayText) !important; cursor: not-allowed !important; pointer-events: none !important; } diff --git a/server/sonar-web/src/main/js/app/styles/init/forms.css b/server/sonar-web/src/main/js/app/styles/init/forms.css index d68a4eb42d8..48af0f4a458 100644 --- a/server/sonar-web/src/main/js/app/styles/init/forms.css +++ b/server/sonar-web/src/main/js/app/styles/init/forms.css @@ -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; } diff --git a/server/sonar-web/src/main/js/app/styles/init/icons.css b/server/sonar-web/src/main/js/app/styles/init/icons.css index 8acdf0079e1..3c96cec1bdc 100644 --- a/server/sonar-web/src/main/js/app/styles/init/icons.css +++ b/server/sonar-web/src/main/js/app/styles/init/icons.css @@ -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 { diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 8498249f48d..9cdc8dafa40 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -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; diff --git a/server/sonar-web/src/main/js/app/styles/sonarcloud.css b/server/sonar-web/src/main/js/app/styles/sonarcloud.css index 4be34dc3c7a..748afde887a 100644 --- a/server/sonar-web/src/main/js/app/styles/sonarcloud.css +++ b/server/sonar-web/src/main/js/app/styles/sonarcloud.css @@ -33,42 +33,6 @@ 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; diff --git a/server/sonar-web/src/main/js/app/types.ts b/server/sonar-web/src/main/js/app/types.ts index 8e36aa98591..803ba673ad2 100644 --- a/server/sonar-web/src/main/js/app/types.ts +++ b/server/sonar-web/src/main/js/app/types.ts @@ -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; diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx index c113f0f46e0..0c387509bc5 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx index 783eba0868d..da9bea478da 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx index 54e8e2214cf..d5bcb2932a8 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx index 65c70964eb8..9153e103b18 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx @@ -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 = [ diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx index 3f085d60410..f826c42cf8a 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx b/server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx index 98510d622fa..a9e35acb217 100644 --- a/server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx +++ b/server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx index 7eee54217b6..7d795c8d1f8 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx @@ -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 index 00000000000..f7882de2ba9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx @@ -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 index 00000000000..25a091e9246 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx @@ -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')} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx index b6c0fa25b32..2767cdfa399 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx @@ -18,17 +18,29 @@ * 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 index 00000000000..6bf245e9088 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx @@ -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} + /> + )} + </> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx index 8357fa54698..9526e064562 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx @@ -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"> diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx index cf5026f87ae..75f1b75d17b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx @@ -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 index 00000000000..8beb62e897c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx @@ -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 index 00000000000..a86ab8dfd11 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx @@ -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(); +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx index 6adb8f9d520..9c1e58367fb 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx @@ -18,9 +18,11 @@ * 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 index 00000000000..6226f8173b7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx @@ -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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx index 38629f87699..4ddeac5d913 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx @@ -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()} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx index aad43c0a619..f8748b45aec 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx @@ -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 index 00000000000..a57042c6f50 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap @@ -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 index 00000000000..ec99dad98ea --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap index 867e8c9f4a0..f2da7e6a62c 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap @@ -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 index 00000000000..be548a156d6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap index 4142791c488..b8bd98adf5b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap @@ -5,7 +5,9 @@ exports[`should render 1`] = ` <label htmlFor="field" > - Label + <strong> + Label + </strong> <em className="mandatory" > diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap index 76102b3bb2f..a52c598379d 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap @@ -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 index 00000000000..07d1c6f16a6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx @@ -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'); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/utils.ts b/server/sonar-web/src/main/js/apps/create/organization/utils.ts index 29fc906c0d7..bfe1825632a 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/utils.ts +++ b/server/sonar-web/src/main/js/apps/create/organization/utils.ts @@ -17,8 +17,10 @@ * 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']) + }; + } +); diff --git a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx b/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx index ae6e431535d..be69f2c4361 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx b/server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx index ed8f05fec88..2025d4bb7f1 100644 --- a/server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx +++ b/server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx @@ -20,10 +20,11 @@ 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; diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx index e06f3a4fed1..6df59fe2ccb 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx index e1b30839a39..1689dc06896 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx index bc0bca0a822..d376085573a 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx @@ -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}> diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx index 57d43dc4682..021b80766e4 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx @@ -26,7 +26,20 @@ 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 diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap index 82dd13c2a2a..cf3e383e573 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx b/server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx index c72610e8991..46ebf59c072 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx +++ b/server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx index 79558f42221..4d55cc25215 100644 --- a/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx +++ b/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx index cb7a50672a7..0e1c185aaac 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx b/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx index 8b4e37b1ec6..c2d8c70efdd 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx b/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx index 5bbcfe9d14b..42fa6f2f116 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx b/server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx index 3b9a46cbb7b..ebec0b9f090 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx @@ -20,10 +20,11 @@ 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; diff --git a/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx index c29cc32f56c..54cae091589 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx index 1b1840e1381..f45dccd05d7 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx b/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx index d20c0fd9ac8..fba7c5875c7 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx @@ -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(); }); diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap index a1eaa7bbd49..69731b0ff8e 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap @@ -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 { diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx index b41edb8e99e..60a134428aa 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx @@ -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 { diff --git a/server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx b/server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx index 9068fb81f94..164f58ed467 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx @@ -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 { diff --git a/server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx b/server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx index 5be1bbc5816..fef52ae8902 100644 --- a/server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx +++ b/server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx @@ -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 index 00000000000..45d8d6498bd --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/Tabs.css @@ -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 index 00000000000..7678b54a2c8 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/Tabs.tsx @@ -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 index 00000000000..72441e0f8cd --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx @@ -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 index 00000000000..2db4cec05a8 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx index 28bd4a38b98..6d4e9744ebd 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/helpers/almIntegrations.ts b/server/sonar-web/src/main/js/helpers/almIntegrations.ts index de03cb6d315..c943f67b90e 100644 --- a/server/sonar-web/src/main/js/helpers/almIntegrations.ts +++ b/server/sonar-web/src/main/js/helpers/almIntegrations.ts @@ -17,6 +17,14 @@ * 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'); diff --git a/server/sonar-web/src/main/js/helpers/organizations.ts b/server/sonar-web/src/main/js/helpers/organizations.ts index 97a57c14238..2c0cdc4f373 100644 --- a/server/sonar-web/src/main/js/helpers/organizations.ts +++ b/server/sonar-web/src/main/js/helpers/organizations.ts @@ -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 index 00000000000..77968f08e3f --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/users.ts @@ -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; +} diff --git a/server/sonar-web/src/main/js/store/users.ts b/server/sonar-web/src/main/js/store/users.ts index 98df5df543f..8440dab6e2e 100644 --- a/server/sonar-web/src/main/js/store/users.ts +++ b/server/sonar-web/src/main/js/store/users.ts @@ -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 { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 1c621e84671..e098be7449e 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 |