Browse Source

SONAR-11321 Create organization from GitHub organization or BitBucket team

* Create api/alm_integration/show_organization and handle only GitHub installation
* Add import from ALM tab in Create Org page
* Do not show error while validating detail input
* Add step to create organization from ALM
* Display a warning if the installation id was not found
* Add Alm link to remote organization in org context
* Create GET api/alm_integration/show_app_info
tags/7.5
Julien Lancelot 5 years ago
parent
commit
f61d654f7d
78 changed files with 1892 additions and 418 deletions
  1. 5
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java
  2. 3
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java
  3. 10
    0
      server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml
  4. 11
    2
      server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java
  5. 5
    15
      server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java
  6. 19
    1
      server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
  7. 15
    1
      server/sonar-web/src/main/js/api/alm-integration.ts
  8. 4
    2
      server/sonar-web/src/main/js/api/organizations.ts
  9. 2
    1
      server/sonar-web/src/main/js/app/components/Landing.tsx
  10. 2
    1
      server/sonar-web/src/main/js/app/components/StartupModal.tsx
  11. 1
    1
      server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
  12. 2
    1
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
  13. 2
    1
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx
  14. 5
    4
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
  15. 2
    1
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx
  16. 1
    1
      server/sonar-web/src/main/js/app/styles/components/menu.css
  17. 3
    3
      server/sonar-web/src/main/js/app/styles/init/forms.css
  18. 2
    2
      server/sonar-web/src/main/js/app/styles/init/icons.css
  19. 5
    0
      server/sonar-web/src/main/js/app/styles/init/misc.css
  20. 0
    36
      server/sonar-web/src/main/js/app/styles/sonarcloud.css
  21. 10
    4
      server/sonar-web/src/main/js/app/types.ts
  22. 1
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx
  23. 1
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx
  24. 1
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx
  25. 2
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx
  26. 1
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx
  27. 1
    1
      server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx
  28. 1
    1
      server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
  29. 104
    0
      server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
  30. 76
    0
      server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx
  31. 130
    92
      server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
  32. 133
    0
      server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx
  33. 3
    2
      server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx
  34. 34
    4
      server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
  35. 74
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
  36. 45
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx
  37. 72
    50
      server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
  38. 86
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx
  39. 1
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx
  40. 50
    15
      server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx
  41. 58
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
  42. 60
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap
  43. 203
    38
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
  44. 74
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap
  45. 3
    1
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap
  46. 5
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap
  47. 32
    0
      server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx
  48. 16
    0
      server/sonar-web/src/main/js/apps/create/organization/utils.ts
  49. 2
    1
      server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx
  50. 2
    1
      server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx
  51. 2
    1
      server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
  52. 2
    1
      server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx
  53. 17
    0
      server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx
  54. 13
    0
      server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx
  55. 37
    0
      server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap
  56. 2
    1
      server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx
  57. 2
    1
      server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx
  58. 4
    3
      server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx
  59. 2
    1
      server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx
  60. 2
    1
      server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx
  61. 2
    1
      server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx
  62. 2
    1
      server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx
  63. 33
    44
      server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx
  64. 3
    4
      server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx
  65. 54
    62
      server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
  66. 2
    1
      server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx
  67. 2
    1
      server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx
  68. 2
    1
      server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx
  69. 60
    0
      server/sonar-web/src/main/js/components/controls/Tabs.css
  70. 77
    0
      server/sonar-web/src/main/js/components/controls/Tabs.tsx
  71. 75
    0
      server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx
  72. 52
    0
      server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap
  73. 2
    1
      server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
  74. 8
    0
      server/sonar-web/src/main/js/helpers/almIntegrations.ts
  75. 2
    1
      server/sonar-web/src/main/js/helpers/organizations.ts
  76. 31
    0
      server/sonar-web/src/main/js/helpers/users.ts
  77. 11
    3
      server/sonar-web/src/main/js/store/users.ts
  78. 11
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 5
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java View File

@@ -52,6 +52,11 @@ public class AlmAppInstallDao implements Dao {
return Optional.ofNullable(mapper.selectByOwner(alm.getId(), ownerId));
}

public Optional<String> getOwerId(DbSession dbSession, ALM alm, String installationId) {
AlmAppInstallMapper mapper = getMapper(dbSession);
return Optional.ofNullable(mapper.selectOwnerId(alm.getId(), installationId));
}

public List<AlmAppInstallDto> findAllWithNoOwnerType(DbSession dbSession) {
return getMapper(dbSession).selectAllWithNoOwnerType();
}

+ 3
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java View File

@@ -29,6 +29,9 @@ public interface AlmAppInstallMapper {
@CheckForNull
AlmAppInstallDto selectByOwner(@Param("almId") String almId, @Param("ownerId") String ownerId);

@CheckForNull
String selectOwnerId(@Param("almId") String almId, @Param("installId") String installId);

List<AlmAppInstallDto> selectAllWithNoOwnerType();

void insert(@Param("uuid") String uuid, @Param("almId") String almId, @Param("ownerId") String ownerId,

+ 10
- 0
server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml View File

@@ -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

+ 11
- 2
server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java View File

@@ -72,7 +72,6 @@ public class AlmAppInstallDaoTest {
assertThat(underTest.selectByOwner(dbSession, BITBUCKETCLOUD, A_OWNER)).isEmpty();
}


@Test
public void selectByOwner_throws_NPE_when_alm_is_null() {
expectAlmNPE();
@@ -94,6 +93,16 @@ public class AlmAppInstallDaoTest {
underTest.selectByOwner(dbSession, GITHUB, EMPTY_STRING);
}

@Test
public void getOwnerId() {
when(uuidFactory.create()).thenReturn(A_UUID);
underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, AN_INSTALL);

assertThat(underTest.getOwerId(dbSession, GITHUB, AN_INSTALL)).contains(A_OWNER);
assertThat(underTest.getOwerId(dbSession, GITHUB, "unknown")).isEmpty();
assertThat(underTest.getOwerId(dbSession, BITBUCKETCLOUD, AN_INSTALL)).isEmpty();
}

@Test
public void insert_throws_NPE_if_alm_is_null() {
expectAlmNPE();
@@ -170,7 +179,7 @@ public class AlmAppInstallDaoTest {
underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, AN_INSTALL);

when(system2.now()).thenReturn(DATE_LATER);
underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, OTHER_INSTALL);
underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER,true, OTHER_INSTALL);

assertThatAlmAppInstall(GITHUB, A_OWNER)
.hasInstallId(OTHER_INSTALL)

+ 5
- 15
server/sonar-server/src/main/java/org/sonar/server/user/AbstractUserSession.java View File

@@ -38,6 +38,7 @@ import org.sonar.server.exceptions.UnauthorizedException;

import static java.lang.String.format;
import static org.apache.commons.lang.StringUtils.defaultString;
import static org.sonar.server.user.UserSession.IdentityProvider.SONARQUBE;

public abstract class AbstractUserSession implements UserSession {
private static final Set<String> PUBLIC_PERMISSIONS = ImmutableSet.of(UserRole.USER, UserRole.CODEVIEWER);
@@ -45,26 +46,15 @@ public abstract class AbstractUserSession implements UserSession {
private static final String AUTHENTICATION_IS_REQUIRED_MESSAGE = "Authentication is required";

protected static Identity computeIdentity(UserDto userDto) {
switch (userDto.getExternalIdentityProvider()) {
case "github":
return new Identity(IdentityProvider.GITHUB, externalIdentityOf(userDto));
case "bitbucket":
return new Identity(IdentityProvider.BITBUCKET, externalIdentityOf(userDto));
case "sonarqube":
return new Identity(IdentityProvider.SONARQUBE, null);
default:
return new Identity(IdentityProvider.OTHER, externalIdentityOf(userDto));
}
IdentityProvider identityProvider = IdentityProvider.getFromKey(userDto.getExternalIdentityProvider());
ExternalIdentity externalIdentity = identityProvider == SONARQUBE ? null : externalIdentityOf(userDto);
return new Identity(identityProvider, externalIdentity);
}

@CheckForNull
private static ExternalIdentity externalIdentityOf(UserDto userDto) {
String externalId = userDto.getExternalId();
String externalLogin = userDto.getExternalLogin();
if (externalId == null && externalLogin == null) {
return null;
}
return new ExternalIdentity(externalId == null ? externalLogin : externalId, externalLogin);
return new ExternalIdentity(externalId, externalLogin);
}

protected static final class Identity {

+ 19
- 1
server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java View File

@@ -19,6 +19,7 @@
*/
package org.sonar.server.user;

import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
@@ -72,7 +73,24 @@ public interface UserSession {
* This enum supports by name only the few providers for which specific code exists.
*/
enum IdentityProvider {
SONARQUBE, GITHUB, BITBUCKET, OTHER
SONARQUBE("sonarqube"), GITHUB("github"), BITBUCKET("bitbucket"), OTHER("other");

String key;

IdentityProvider(String key) {
this.key = key;
}

public String getKey() {
return key;
}

public static IdentityProvider getFromKey(String key) {
return Arrays.stream(IdentityProvider.values())
.filter(i -> i.getKey().equals(key))
.findAny()
.orElse(OTHER);
}
}

/**

+ 15
- 1
server/sonar-web/src/main/js/api/alm-integration.ts View File

@@ -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;

+ 4
- 2
server/sonar-web/src/main/js/api/organizations.ts View File

@@ -39,12 +39,12 @@ export function getOrganization(key: string): Promise<Organization | undefined>
}

interface GetOrganizationNavigation {
adminPages: Array<{ key: string; name: string }>;
canAdmin: boolean;
canDelete: boolean;
canProvisionProjects: boolean;
isDefault: boolean;
pages: Array<{ key: string; name: string }>;
adminPages: Array<{ key: string; name: string }>;
}

export function getOrganizationNavigation(key: string): Promise<GetOrganizationNavigation> {
@@ -54,7 +54,9 @@ export function getOrganizationNavigation(key: string): Promise<GetOrganizationN
);
}

export function createOrganization(data: OrganizationBase): Promise<Organization> {
export function createOrganization(
data: OrganizationBase & { installId?: string }
): Promise<Organization> {
return postJSON('/api/organizations/create', data).then(r => r.organization, throwGlobalError);
}


+ 2
- 1
server/sonar-web/src/main/js/app/components/Landing.tsx View File

@@ -21,9 +21,10 @@ import * as React from 'react';
import * as PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Location } from 'history';
import { CurrentUser, isLoggedIn } from '../types';
import { CurrentUser } from '../types';
import { getCurrentUser, Store } from '../../store/rootReducer';
import { getHomePageUrl } from '../../helpers/urls';
import { isLoggedIn } from '../../helpers/users';

interface StateProps {
currentUser: CurrentUser | undefined;

+ 2
- 1
server/sonar-web/src/main/js/app/components/StartupModal.tsx View File

@@ -21,7 +21,7 @@ import * as React from 'react';
import * as PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withRouter, WithRouterProps } from 'react-router';
import { CurrentUser, isLoggedIn } from '../types';
import { CurrentUser } from '../types';
import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates';
import { EditionKey } from '../../apps/marketplace/utils';
import { getCurrentUser, getAppState, Store } from '../../store/rootReducer';
@@ -32,6 +32,7 @@ import { save, get } from '../../helpers/storage';
import { isSonarCloud } from '../../helpers/system';
import { skipOnboarding } from '../../api/users';
import { lazyLoad } from '../../components/lazyLoad';
import { isLoggedIn } from '../../helpers/users';

const OnboardingModal = lazyLoad(() => import('../../apps/tutorials/onboarding/OnboardingModal'));
const LicensePromptModal = lazyLoad(

+ 1
- 1
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx View File

@@ -24,7 +24,6 @@ import {
BranchLike,
Component,
CurrentUser,
isLoggedIn,
HomePageType,
HomePage,
Measure
@@ -43,6 +42,7 @@ import {
isPullRequest
} from '../../../../helpers/branches';
import { translate } from '../../../../helpers/l10n';
import { isLoggedIn } from '../../../../helpers/users';
import { getCurrentUser, Store } from '../../../../store/rootReducer';

interface StateProps {

+ 2
- 1
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx View File

@@ -27,12 +27,13 @@ import GlobalNavUserContainer from './GlobalNavUserContainer';
import Search from '../../search/Search';
import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
import * as theme from '../../../theme';
import { CurrentUser, AppState, isLoggedIn } from '../../../types';
import { CurrentUser, AppState } from '../../../types';
import NavBar from '../../../../components/nav/NavBar';
import { lazyLoad } from '../../../../components/lazyLoad';
import { getCurrentUser, getAppState, Store } from '../../../../store/rootReducer';
import { SuggestionLink } from '../../embed-docs-modal/SuggestionsProvider';
import { isSonarCloud } from '../../../../helpers/system';
import { isLoggedIn } from '../../../../helpers/users';
import './GlobalNav.css';

const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus');

+ 2
- 1
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx View File

@@ -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'>;

+ 5
- 4
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx View File

@@ -22,12 +22,13 @@ import { Link, withRouter, WithRouterProps } from 'react-router';
import CreateFormShim from '../../../../apps/portfolio/components/CreateFormShim';
import Dropdown from '../../../../components/controls/Dropdown';
import PlusIcon from '../../../../components/icons-components/PlusIcon';
import { AppState, hasGlobalPermission, LoggedInUser } from '../../../types';
import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
import { AppState, LoggedInUser } from '../../../types';
import { getExtensionStart } from '../../extensions/utils';
import { isSonarCloud } from '../../../../helpers/system';
import { translate } from '../../../../helpers/l10n';
import { getComponentNavigation } from '../../../../api/nav';
import { translate } from '../../../../helpers/l10n';
import { isSonarCloud } from '../../../../helpers/system';
import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
import { hasGlobalPermission } from '../../../../helpers/users';

interface Props {
appState: Pick<AppState, 'qualifiers'>;

+ 2
- 1
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx View File

@@ -22,12 +22,13 @@ import { sortBy } from 'lodash';
import * as PropTypes from 'prop-types';
import { Link } from 'react-router';
import * as theme from '../../../theme';
import { CurrentUser, LoggedInUser, isLoggedIn, Organization } from '../../../types';
import { CurrentUser, LoggedInUser, Organization } from '../../../types';
import Avatar from '../../../../components/ui/Avatar';
import OrganizationListItem from '../../../../components/ui/OrganizationListItem';
import { translate } from '../../../../helpers/l10n';
import { getBaseUrl } from '../../../../helpers/urls';
import Dropdown from '../../../../components/controls/Dropdown';
import { isLoggedIn } from '../../../../helpers/users';

interface Props {
appState: { organizationsEnabled?: boolean };

+ 1
- 1
server/sonar-web/src/main/js/app/styles/components/menu.css View File

@@ -72,7 +72,7 @@
}

.menu > li > a.disabled {
color: #bbb !important;
color: var(--disableGrayText) !important;
cursor: not-allowed !important;
pointer-events: none !important;
}

+ 3
- 3
server/sonar-web/src/main/js/app/styles/init/forms.css View File

@@ -249,8 +249,8 @@ label[for] {
}

.radio-toggle input[type='radio']:disabled + label {
color: #bbb;
border-color: #ddd;
background: #ebebeb;
color: var(--disableGrayText);
border-color: var(--disableGrayBorder);
background: var(--disableGrayBg);
cursor: not-allowed;
}

+ 2
- 2
server/sonar-web/src/main/js/app/styles/init/icons.css View File

@@ -84,12 +84,12 @@ a[class*=' icon-'] {
}

.icon-checkbox-disabled:before {
border: 1px solid #bbb;
border: 1px solid var(--disableGrayText);
cursor: not-allowed;
}

.icon-checkbox-disabled.icon-checkbox-checked:before {
background-color: #bbb;
background-color: var(--disableGrayText);
}

.icon-checkbox-invisible {

+ 5
- 0
server/sonar-web/src/main/js/app/styles/init/misc.css View File

@@ -299,6 +299,11 @@ td.big-spacer-top {
align-items: center;
}

.display-inline-flex-baseline {
display: inline-flex !important;
align-items: baseline;
}

.display-inline-flex-center {
display: inline-flex !important;
align-items: center;

+ 0
- 36
server/sonar-web/src/main/js/app/styles/sonarcloud.css View File

@@ -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;

+ 10
- 4
server/sonar-web/src/main/js/app/types.ts View File

@@ -23,6 +23,14 @@ export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// Type ordered alphabetically to prevent merge conflicts

export interface AlmApplication extends IdentityProvider {
installationUrl: string;
}
export interface AlmOrganization extends OrganizationBase {
key: string;
type: 'ORGANIZATION' | 'USER';
}

export interface AlmRepository {
label: string;
installationKey: string;
@@ -277,10 +285,6 @@ export interface IdentityProvider {
name: string;
}

export function isLoggedIn(user: CurrentUser): user is LoggedInUser {
return user.isLoggedIn;
}

export function hasGlobalPermission(user: CurrentUser, permission: string): boolean {
if (!user.permissions) {
return false;
@@ -476,6 +480,8 @@ export interface Notification {
}

export interface Organization extends OrganizationBase {
almId?: string;
almRepoUrl?: string;
adminPages?: Extension[];
canAdmin?: boolean;
canDelete?: boolean;

+ 1
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/AsAService.tsx View File

@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
import SQPageContainer from './components/SQPageContainer';
import SQStartUsing from './components/SQStartUsing';
import SQTopNav from './components/SQTopNav';
import { isLoggedIn } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import { getBaseUrl } from '../../../helpers/urls';
import './style.css';


+ 1
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/AzureDevOps.tsx View File

@@ -21,7 +21,7 @@ import * as React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'react-router';
import SQPageContainer from './components/SQPageContainer';
import { isLoggedIn } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import { getBaseUrl } from '../../../helpers/urls';
import './style.css';


+ 1
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/BranchAnalysis.tsx View File

@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
import SQPageContainer from './components/SQPageContainer';
import SQStartUsing from './components/SQStartUsing';
import SQTopNav from './components/SQTopNav';
import { isLoggedIn } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import { getBaseUrl } from '../../../helpers/urls';
import './style.css';


+ 2
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/Contact.tsx View File

@@ -23,8 +23,9 @@ import { Link } from 'react-router';
import { Location } from 'history';
import SQPageContainer from './components/SQPageContainer';
import Select from '../../../components/controls/Select';
import { isLoggedIn, Organization } from '../../../app/types';
import { Alert } from '../../../components/ui/Alert';
import { Organization } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import './style.css';

const CATEGORIES = [

+ 1
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/SQHome.tsx View File

@@ -24,7 +24,7 @@ import LoginButtons from './components/LoginButtons';
import Pricing from './components/Pricing';
import SQPageContainer from './components/SQPageContainer';
import StartUsing from './components/StartUsing';
import { isLoggedIn } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import { getBaseUrl } from '../../../helpers/urls';
import './style.css';


+ 1
- 1
server/sonar-web/src/main/js/apps/about/sonarcloud/SonarLintIntegration.tsx View File

@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
import SQPageContainer from './components/SQPageContainer';
import SQStartUsing from './components/SQStartUsing';
import SQTopNav from './components/SQTopNav';
import { isLoggedIn } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';
import { getBaseUrl } from '../../../helpers/urls';
import './style.css';


+ 1
- 1
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx View File

@@ -41,13 +41,13 @@ import {
ComponentMeasure,
ComponentMeasureEnhanced,
CurrentUser,
isLoggedIn,
Metric,
Paging,
MeasureEnhanced,
Period
} from '../../../app/types';
import { RequestData } from '../../../helpers/request';
import { isLoggedIn } from '../../../helpers/users';

interface Props {
branchLike?: BranchLike;

+ 104
- 0
server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx View File

@@ -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}
/>
);
}
}

+ 76
- 0
server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx View File

@@ -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')}
/>
);
}
}

+ 130
- 92
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx View File

@@ -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)
)
);

+ 133
- 0
server/sonar-web/src/main/js/apps/create/organization/ManualOrganizationCreate.tsx View File

@@ -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}
/>
)}
</>
);
}
}

+ 3
- 2
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx View File

@@ -29,6 +29,7 @@ interface Props {
error: string | undefined;
id: string;
isSubmitting: boolean;
isValidating: boolean;
label: React.ReactNode;
name: string;
onBlur: React.FocusEventHandler;
@@ -39,12 +40,12 @@ interface Props {
}

export default function OrganizationDetailsInput(props: Props) {
const hasError = props.dirty && props.touched && props.error !== undefined;
const hasError = props.dirty && props.touched && !props.isValidating && props.error !== undefined;
const isValid = props.dirty && props.touched && props.error === undefined;
return (
<div>
<label htmlFor={props.id}>
{props.label}
<strong>{props.label}</strong>
{props.required && <em className="mandatory">*</em>}
</label>
<div className="little-spacer-top spacer-bottom">

+ 34
- 4
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx View File

@@ -26,6 +26,7 @@ import { translate } from '../../../helpers/l10n';
import { ResetButtonLink, SubmitButton } from '../../../components/ui/buttons';
import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon';
import DropdownIcon from '../../../components/icons-components/DropdownIcon';
import { getHostUrl } from '../../../helpers/urls';
import { OrganizationBase } from '../../../app/types';
import { getOrganization } from '../../../api/organizations';

@@ -40,11 +41,13 @@ const initialValues: Values = {
};

interface Props {
description?: React.ReactNode;
finished: boolean;
onContinue: (organization: Required<OrganizationBase>) => Promise<void>;
onOpen: () => void;
open: boolean;
organization?: OrganizationBase & { key: string };
submitText: string;
}

interface State {
@@ -118,10 +121,17 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
handleChange,
isSubmitting,
isValid,
isValidating,
touched,
values
} = props;
const commonProps = { dirty, isSubmitting, onBlur: handleBlur, onChange: handleChange };
const commonProps = {
dirty,
isValidating,
isSubmitting,
onBlur: handleBlur,
onChange: handleChange
};
return (
<>
<OrganizationDetailsInput
@@ -134,7 +144,14 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
required={true}
touched={touched.key}
value={values.key}>
{props => <input autoFocus={true} maxLength={255} {...props} />}
{props => (
<div className="display-inline-flex-baseline">
<span className="little-spacer-right">
{getHostUrl().replace(/https*:\/\//, '') + '/organizations/'}
</span>
<input autoFocus={true} maxLength={255} {...props} />
</div>
)}
</OrganizationDetailsInput>
<div className="big-spacer-top">
<ResetButtonLink onClick={this.handleAdditionalClick}>
@@ -170,7 +187,19 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
name="avatar"
touched={touched.avatar && values.avatar !== ''}
value={values.avatar}>
{props => <input {...props} />}
{props => (
<>
{values.avatar && (
<img
alt=""
className="display-block spacer-bottom rounded"
src={values.avatar}
width={48}
/>
)}
<input {...props} />
</>
)}
</OrganizationDetailsInput>
</div>
<div className="big-spacer-top">
@@ -199,7 +228,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
</div>
</div>
<div className="big-spacer-top">
<SubmitButton disabled={isSubmitting || !isValid}>{translate('continue')}</SubmitButton>
<SubmitButton disabled={isSubmitting || !isValid}>{this.props.submitText}</SubmitButton>
</div>
</>
);
@@ -208,6 +237,7 @@ export default class OrganizationDetailsStep extends React.PureComponent<Props,
renderForm = () => {
return (
<div className="boxed-group-inner">
{this.props.description}
<ValidationForm<Values>
initialValues={this.getInitialValues()}
isInitialValid={this.props.organization !== undefined}

+ 74
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx View File

@@ -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}
/>
);
}

+ 45
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx View File

@@ -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();
}

+ 72
- 50
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx View File

@@ -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);
});
}

+ 86
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/ManualOrganizationCreate-test.tsx View File

@@ -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}
/>
);
}

+ 1
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx View File

@@ -30,6 +30,7 @@ it('should render', () => {
error="This field is bad!"
id="field"
isSubmitting={true}
isValidating={false}
label="Label"
name="field"
onBlur={jest.fn()}

+ 50
- 15
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx View File

@@ -38,6 +38,7 @@ it('should render form', () => {
onContinue={jest.fn()}
onOpen={jest.fn()}
open={true}
submitText="continue"
/>
);
expect(wrapper).toMatchSnapshot();
@@ -58,22 +59,29 @@ it('should render form', () => {
).toBe(false);
});

it('should validate', () => {
it('should validate', async () => {
const wrapper = shallow(
<OrganizationDetailsStep
finished={false}
onContinue={jest.fn()}
onOpen={jest.fn()}
open={true}
submitText="continue"
/>
);
const instance = wrapper.instance() as OrganizationDetailsStep;

expect(
instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
await expect(
instance.handleValidate({
avatar: '',
description: '',
name: '',
key: 'foo',
url: ''
})
).resolves.toEqual({});

expect(
await expect(
instance.handleValidate({
avatar: '',
description: '',
@@ -81,13 +89,21 @@ it('should validate', () => {
key: 'x'.repeat(256),
url: ''
})
).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.error' });
).rejects.toEqual({
key: 'onboarding.create_organization.organization_name.error'
});

expect(
instance.handleValidate({ avatar: 'bla', description: '', name: '', key: 'foo', url: '' })
await expect(
instance.handleValidate({
avatar: 'bla',
description: '',
name: '',
key: 'foo',
url: ''
})
).rejects.toEqual({ avatar: 'onboarding.create_organization.avatar.error' });

expect(
await expect(
instance.handleValidate({
avatar: '',
description: '',
@@ -95,16 +111,34 @@ it('should validate', () => {
key: 'foo',
url: ''
})
).rejects.toEqual({ name: 'onboarding.create_organization.display_name.error' });
).rejects.toEqual({
name: 'onboarding.create_organization.display_name.error'
});

expect(
instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: 'bla' })
).rejects.toEqual({ url: 'onboarding.create_organization.url.error' });
await expect(
instance.handleValidate({
avatar: '',
description: '',
name: '',
key: 'foo',
url: 'bla'
})
).rejects.toEqual({
url: 'onboarding.create_organization.url.error'
});

(getOrganization as jest.Mock).mockResolvedValue({});
expect(
instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.taken' });
await expect(
instance.handleValidate({
avatar: '',
description: '',
name: '',
key: 'foo',
url: ''
})
).rejects.toEqual({
key: 'onboarding.create_organization.organization_name.taken'
});
});

it('should render result', () => {
@@ -115,6 +149,7 @@ it('should render result', () => {
onOpen={jest.fn()}
open={false}
organization={{ avatar: '', description: '', key: 'org', name: 'Organization', url: '' }}
submitText="continue"
/>
);
expect(wrapper.dive()).toMatchSnapshot();

+ 58
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap View File

@@ -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",
}
}
/>
`;

+ 60
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap View File

@@ -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>
`;

+ 203
- 38
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render and create organization 1`] = `
exports[`should render with auto tab displayed 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
@@ -42,37 +42,47 @@ exports[`should render and create organization 1`] = `
/>
</p>
</header>
<OrganizationDetailsStep
finished={false}
onContinue={[Function]}
onOpen={[Function]}
open={true}
/>
<PlanStep
createOrganization={[Function]}
deleteOrganization={[Function]}
onFreePlanChoose={[Function]}
onPaidPlanChoose={[Function]}
open={false}
startingPrice="billing.price_format.10"
subscriptionPlans={
<Tabs
onChange={[Function]}
selected="auto"
tabs={
Array [
Object {
"maxNcloc": 100000,
"price": 10,
"key": "auto",
"node": <React.Fragment>
onboarding.create_organization.import_organization.github
<span
className="rounded alert alert-small spacer-left display-inline-block alert-info"
>
beta
</span>
</React.Fragment>,
},
Object {
"maxNcloc": 250000,
"price": 75,
"disabled": false,
"key": "manual",
"node": "onboarding.create_organization.create_manually",
},
]
}
/>
<AutoOrganizationCreate
almApplication={
Object {
"backgroundColor": "blue",
"iconPath": "icon/path",
"installationUrl": "https://alm.installation.url",
"key": "github",
"name": "GitHub",
}
}
onOrgCreated={[Function]}
/>
</div>
</Fragment>
`;

exports[`should render and create organization 2`] = `
exports[`should render with auto tab selected and manual disabled 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
@@ -114,28 +124,101 @@ exports[`should render and create organization 2`] = `
/>
</p>
</header>
<OrganizationDetailsStep
finished={true}
onContinue={[Function]}
onOpen={[Function]}
open={false}
organization={
<Tabs
onChange={[Function]}
selected="auto"
tabs={
Array [
Object {
"key": "auto",
"node": <React.Fragment>
onboarding.create_organization.import_organization.github
<span
className="rounded alert alert-small spacer-left display-inline-block alert-info"
>
beta
</span>
</React.Fragment>,
},
Object {
"disabled": true,
"key": "manual",
"node": "onboarding.create_organization.create_manually",
},
]
}
/>
<AutoOrganizationCreate
almApplication={
Object {
"avatar": "http://example.com/avatar",
"description": "description-foo",
"key": "key-foo",
"name": "name-foo",
"url": "http://example.com/foo",
"backgroundColor": "blue",
"iconPath": "icon/path",
"installationUrl": "https://alm.installation.url",
"key": "github",
"name": "GitHub",
}
}
almInstallId="foo"
almOrganization={
Object {
"avatar": "https://avatars3.githubusercontent.com/u/37629810?v=4",
"description": "Continuous Code Quality",
"key": "sonarsource",
"name": "SonarSource",
"type": "ORGANIZATION",
"url": "https://www.sonarsource.com",
}
}
onOrgCreated={[Function]}
/>
<PlanStep
createOrganization={[Function]}
deleteOrganization={[Function]}
onFreePlanChoose={[Function]}
onPaidPlanChoose={[Function]}
open={true}
startingPrice="billing.price_format.10"
</div>
</Fragment>
`;

exports[`should render with manual tab displayed 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="onboarding.create_organization.page.header"
titleTemplate="%s"
/>
<div
className="sonarcloud page page-limited"
>
<header
className="page-header"
>
<h1
className="page-title big-spacer-bottom"
>
onboarding.create_organization.page.header
</h1>
<p
className="page-description"
>
<FormattedMessage
defaultMessage="onboarding.create_organization.page.description"
id="onboarding.create_organization.page.description"
values={
Object {
"break": <br />,
"more": <Link
onlyActiveOnIndex={false}
style={Object {}}
target="_blank"
to="/documentation/sonarcloud-pricing/"
>
learn_more
</Link>,
"price": "billing.price_format.10",
}
}
/>
</p>
</header>
<ManualOrganizationCreate
onOrgCreated={[Function]}
subscriptionPlans={
Array [
Object {
@@ -152,3 +235,85 @@ exports[`should render and create organization 2`] = `
</div>
</Fragment>
`;

exports[`should switch tabs 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="onboarding.create_organization.page.header"
titleTemplate="%s"
/>
<div
className="sonarcloud page page-limited"
>
<header
className="page-header"
>
<h1
className="page-title big-spacer-bottom"
>
onboarding.create_organization.page.header
</h1>
<p
className="page-description"
>
<FormattedMessage
defaultMessage="onboarding.create_organization.page.description"
id="onboarding.create_organization.page.description"
values={
Object {
"break": <br />,
"more": <Link
onlyActiveOnIndex={false}
style={Object {}}
target="_blank"
to="/documentation/sonarcloud-pricing/"
>
learn_more
</Link>,
"price": "billing.price_format.10",
}
}
/>
</p>
</header>
<Tabs
onChange={[Function]}
selected="auto"
tabs={
Array [
Object {
"key": "auto",
"node": <React.Fragment>
onboarding.create_organization.import_organization.github
<span
className="rounded alert alert-small spacer-left display-inline-block alert-info"
>
beta
</span>
</React.Fragment>,
},
Object {
"disabled": false,
"key": "manual",
"node": "onboarding.create_organization.create_manually",
},
]
}
/>
<AutoOrganizationCreate
almApplication={
Object {
"backgroundColor": "blue",
"iconPath": "icon/path",
"installationUrl": "https://alm.installation.url",
"key": "github",
"name": "GitHub",
}
}
onOrgCreated={[Function]}
/>
</div>
</Fragment>
`;

+ 74
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ManualOrganizationCreate-test.tsx.snap View File

@@ -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>
`;

+ 3
- 1
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap View File

@@ -5,7 +5,9 @@ exports[`should render 1`] = `
<label
htmlFor="field"
>
Label
<strong>
Label
</strong>
<em
className="mandatory"
>

+ 5
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap View File

@@ -64,6 +64,7 @@ exports[`should render form 3`] = `
dirty={false}
id="organization-key"
isSubmitting={false}
isValidating={false}
label="onboarding.create_organization.organization_name"
name="key"
onBlur={[Function]}
@@ -98,6 +99,7 @@ exports[`should render form 3`] = `
dirty={false}
id="organization-display-name"
isSubmitting={false}
isValidating={false}
label="onboarding.create_organization.display_name"
name="name"
onBlur={[Function]}
@@ -115,6 +117,7 @@ exports[`should render form 3`] = `
dirty={false}
id="organization-avatar"
isSubmitting={false}
isValidating={false}
label="onboarding.create_organization.avatar"
name="avatar"
onBlur={[Function]}
@@ -131,6 +134,7 @@ exports[`should render form 3`] = `
dirty={false}
id="organization-description"
isSubmitting={false}
isValidating={false}
label="description"
name="description"
onBlur={[Function]}
@@ -147,6 +151,7 @@ exports[`should render form 3`] = `
dirty={false}
id="organization-url"
isSubmitting={false}
isValidating={false}
label="onboarding.create_organization.url"
name="url"
onBlur={[Function]}

+ 32
- 0
server/sonar-web/src/main/js/apps/create/organization/__tests__/utils-test.tsx View File

@@ -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');
});
});

+ 16
- 0
server/sonar-web/src/main/js/apps/create/organization/utils.ts View File

@@ -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'])
};
}
);

+ 2
- 1
server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx View File

@@ -20,7 +20,8 @@
import * as React from 'react';
import { withRouter, WithRouterProps } from 'react-router';
import { withCurrentUser } from './withCurrentUser';
import { CurrentUser, isLoggedIn } from '../../../app/types';
import { CurrentUser } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';

export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) {
const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';

+ 2
- 1
server/sonar-web/src/main/js/apps/issues/IssuesPageSelector.tsx View File

@@ -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;

+ 2
- 1
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx View File

@@ -21,7 +21,7 @@ import * as React from 'react';
import { pickBy, sortBy } from 'lodash';
import { searchAssignees } from '../utils';
import { searchIssueTags, bulkChangeIssues } from '../../../api/issues';
import { Component, CurrentUser, Issue, Paging, isLoggedIn, IssueType } from '../../../app/types';
import { Component, CurrentUser, Issue, Paging, IssueType } from '../../../app/types';
import throwGlobalError from '../../../app/utils/throwGlobalError';
import MarkdownTips from '../../../components/common/MarkdownTips';
import SearchSelect from '../../../components/controls/SearchSelect';
@@ -35,6 +35,7 @@ import { SubmitButton } from '../../../components/ui/buttons';
import IssueTypeIcon from '../../../components/ui/IssueTypeIcon';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Alert } from '../../../components/ui/Alert';
import { isLoggedIn } from '../../../helpers/users';

interface AssigneeOption {
avatar?: string;

+ 2
- 1
server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx View File

@@ -22,7 +22,8 @@ import { connect } from 'react-redux';
import { RouterState } from 'react-router';
import { getCurrentUser, getOrganizationByKey, Store } from '../../../store/rootReducer';
import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization';
import { Organization, CurrentUser, isLoggedIn } from '../../../app/types';
import { Organization, CurrentUser } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';

interface StateToProps {
currentUser: CurrentUser;

+ 17
- 0
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx View File

@@ -24,6 +24,8 @@ import OrganizationAvatar from '../../../components/common/OrganizationAvatar';
import Dropdown from '../../../components/controls/Dropdown';
import DropdownIcon from '../../../components/icons-components/DropdownIcon';
import OrganizationListItem from '../../../components/ui/OrganizationListItem';
import { sanitizeAlmId } from '../../../helpers/almIntegrations';
import { getBaseUrl } from '../../../helpers/urls';

interface Props {
organization: Organization;
@@ -56,6 +58,21 @@ export default function OrganizationNavigationHeader({ organization, organizatio
) : (
<span className="spacer-left">{organization.name}</span>
)}
{organization.almRepoUrl && (
<a
className="link-no-underline"
href={organization.almRepoUrl}
rel="noopener noreferrer"
target="_blank">
<img
alt={sanitizeAlmId(organization.almId)}
className="text-text-top spacer-left"
height={16}
src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(organization.almId)}.svg`}
width={16}
/>
</a>
)}
{organization.description != null && (
<div className="navbar-context-description">
<p className="text-limited text-top" title={organization.description}>

+ 13
- 0
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx View File

@@ -23,10 +23,23 @@ import OrganizationNavigationHeader from '../OrganizationNavigationHeader';
import { Visibility } from '../../../../app/types';

it('renders', () => {
expect(
shallow(
<OrganizationNavigationHeader
organization={{ key: 'foo', name: 'Foo', projectVisibility: Visibility.Public }}
organizations={[]}
/>
)
).toMatchSnapshot();
});

it('renders with alm integration', () => {
expect(
shallow(
<OrganizationNavigationHeader
organization={{
almId: 'github',
almRepoUrl: 'https://github.com/foo',
key: 'foo',
name: 'Foo',
projectVisibility: Visibility.Public

+ 37
- 0
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap View File

@@ -62,3 +62,40 @@ exports[`renders dropdown 1`] = `
</a>
</Dropdown>
`;

exports[`renders with alm integration 1`] = `
<header
className="navbar-context-header"
>
<OrganizationAvatar
organization={
Object {
"almId": "github",
"almRepoUrl": "https://github.com/foo",
"key": "foo",
"name": "Foo",
"projectVisibility": "public",
}
}
/>
<span
className="spacer-left"
>
Foo
</span>
<a
className="link-no-underline"
href="https://github.com/foo"
rel="noopener noreferrer"
target="_blank"
>
<img
alt="github"
className="text-text-top spacer-left"
height={16}
src="/images/sonarcloud/github.svg"
width={16}
/>
</a>
</header>
`;

+ 2
- 1
server/sonar-web/src/main/js/apps/overview/components/SonarCloudEmptyOverview.tsx View File

@@ -22,9 +22,10 @@ import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import AnalyzeTutorial from '../../tutorials/analyzeProject/AnalyzeTutorial';
import MetaContainer from '../meta/MetaContainer';
import { BranchLike, Component, CurrentUser, isLoggedIn } from '../../../app/types';
import { BranchLike, Component, CurrentUser } from '../../../app/types';
import { isLongLivingBranch, isBranch, isMainBranch } from '../../../helpers/branches';
import { translate } from '../../../helpers/l10n';
import { isLoggedIn } from '../../../helpers/users';
import { getCurrentUser, Store } from '../../../store/rootReducer';
import '../../../app/styles/sonarcloud.css';
import { Alert } from '../../../components/ui/Alert';

+ 2
- 1
server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx View File

@@ -22,7 +22,8 @@ import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessI
import { ReportStatus, subscribe, unsubscribe } from '../../../api/report';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Button } from '../../../components/ui/buttons';
import { CurrentUser, isLoggedIn } from '../../../app/types';
import { CurrentUser } from '../../../app/types';
import { isLoggedIn } from '../../../helpers/users';

interface Props {
component: string;

+ 4
- 3
server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx View File

@@ -26,10 +26,11 @@ import ProjectsList from './ProjectsList';
import PageSidebar from './PageSidebar';
import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
import Visualizations from '../visualizations/Visualizations';
import { CurrentUser, isLoggedIn, Organization } from '../../../app/types';
import { CurrentUser, Organization } from '../../../app/types';
import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import ListFooter from '../../../components/controls/ListFooter';
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
import { translate } from '../../../helpers/l10n';
import { get, save } from '../../../helpers/storage';
import { RawQuery } from '../../../helpers/query';
@@ -37,9 +38,9 @@ import { Project, Facets } from '../types';
import { fetchProjects, parseSorting, SORTING_SWITCH } from '../utils';
import { parseUrlQuery, Query, hasFilterParams, hasVisualizationParams } from '../query';
import { isSonarCloud } from '../../../helpers/system';
import { isLoggedIn } from '../../../helpers/users';
import '../../../components/search-navigator.css';
import '../styles.css';
import DeferredSpinner from '../../../components/common/DeferredSpinner';

export interface Props {
currentUser: CurrentUser;

+ 2
- 1
server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx View File

@@ -23,8 +23,9 @@ import AllProjectsContainer from './AllProjectsContainer';
import { PROJECTS_DEFAULT_FILTER, PROJECTS_FAVORITE, PROJECTS_ALL } from '../utils';
import { get } from '../../../helpers/storage';
import { searchProjects } from '../../../api/components';
import { CurrentUser, isLoggedIn } from '../../../app/types';
import { CurrentUser } from '../../../app/types';
import { isSonarCloud } from '../../../helpers/system';
import { isLoggedIn } from '../../../helpers/users';

interface Props {
currentUser: CurrentUser;

+ 2
- 1
server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx View File

@@ -21,8 +21,9 @@ import * as React from 'react';
import * as PropTypes from 'prop-types';
import { translate } from '../../../helpers/l10n';
import { Button } from '../../../components/ui/buttons';
import { Organization, CurrentUser, isLoggedIn, hasGlobalPermission } from '../../../app/types';
import { Organization, CurrentUser } from '../../../app/types';
import { isSonarCloud } from '../../../helpers/system';
import { hasGlobalPermission, isLoggedIn } from '../../../helpers/users';

interface Props {
organization?: Organization;

+ 2
- 1
server/sonar-web/src/main/js/apps/projects/components/FavoriteFilter.tsx View File

@@ -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;

+ 2
- 1
server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx View File

@@ -23,12 +23,13 @@ import PerspectiveSelect from './PerspectiveSelect';
import ProjectsSortingSelect from './ProjectsSortingSelect';
import SearchFilterContainer from '../filters/SearchFilterContainer';
import Tooltip from '../../../components/controls/Tooltip';
import { CurrentUser, isLoggedIn, HomePageType } from '../../../app/types';
import { CurrentUser, HomePageType } from '../../../app/types';
import HomePageSelect from '../../../components/controls/HomePageSelect';
import { translate } from '../../../helpers/l10n';
import { RawQuery } from '../../../helpers/query';
import { Project } from '../types';
import { isSonarCloud } from '../../../helpers/system';
import { isLoggedIn } from '../../../helpers/users';

interface Props {
currentUser: CurrentUser;

+ 33
- 44
server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx View File

@@ -27,14 +27,17 @@ import AutoProjectCreate from './AutoProjectCreate';
import ManualProjectCreate from './ManualProjectCreate';
import { serializeQuery, Query, parseQuery } from './utils';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import Tabs from '../../../components/controls/Tabs';
import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
import { getCurrentUser, Store } from '../../../store/rootReducer';
import { addGlobalErrorMessage } from '../../../store/globalMessages';
import { skipOnboarding as skipOnboardingAction } from '../../../store/users';
import { CurrentUser, IdentityProvider, isLoggedIn, LoggedInUser } from '../../../app/types';
import { CurrentUser, IdentityProvider, LoggedInUser } from '../../../app/types';
import { skipOnboarding, getIdentityProviders } from '../../../api/users';
import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations';
import { translate } from '../../../helpers/l10n';
import { getProjectUrl } from '../../../helpers/urls';
import { isLoggedIn } from '../../../helpers/users';
import '../../../app/styles/sonarcloud.css';

interface OwnProps {
@@ -69,7 +72,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
if (query.error) {
this.props.addGlobalErrorMessage(query.error);
}
if (!this.canAutoCreate()) {
if (!hasAdvancedALMIntegration(this.props.currentUser)) {
this.setState({ loading: false });
this.updateQuery({ manual: true });
} else {
@@ -102,10 +105,6 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
}
};

canAutoCreate = ({ currentUser } = this.props) => {
return ['bitbucket', 'github'].includes((currentUser as LoggedInUser).externalProvider || '');
};

fetchIdentityProviders = () => {
getIdentityProviders().then(
({ identityProviders }) => {
@@ -127,14 +126,8 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
);
};

showAuto = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.updateQuery({ manual: false });
};

showManual = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.updateQuery({ manual: true });
onTabChange = (tab: 'auto' | 'manual') => {
this.updateQuery({ manual: tab === 'manual' });
};

updateQuery = (changes: Partial<Query>) => {
@@ -152,49 +145,45 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
}

const { identityProvider, loading } = this.state;
const displayManual = parseQuery(this.props.location.query).manual;
const header = translate('onboarding.create_project.header');
const hasAutoProvisioning = this.canAutoCreate() && identityProvider;
const query = parseQuery(this.props.location.query);
const header = translate('onboarding.create_project.header');
const hasAutoProvisioning = hasAdvancedALMIntegration(currentUser) && identityProvider;
return (
<>
<Helmet title={header} titleTemplate="%s" />
<div className="sonarcloud page page-limited">
<div className="page-header">
<header className="page-header">
<h1 className="page-title">{header}</h1>
</div>
</header>
{loading ? (
<DeferredSpinner />
) : (
<>
{hasAutoProvisioning && (
<ul className="flex-tabs">
<li>
<a
className={classNames('js-auto', { selected: !displayManual })}
href="#"
onClick={this.showAuto}>
{translate('onboarding.create_project.select_repositories')}
<div
className={classNames('beta-badge spacer-left', {
'is-muted': displayManual
})}>
{translate('beta')}
</div>
</a>
</li>
<li>
<a
className={classNames('js-manual', { selected: displayManual })}
href="#"
onClick={this.showManual}>
{translate('onboarding.create_project.create_manually')}
</a>
</li>
</ul>
<Tabs
onChange={this.onTabChange}
selected={query.manual ? 'manual' : 'auto'}
tabs={[
{
key: 'auto',
node: (
<>
{translate('onboarding.create_project.select_repositories')}
<span
className={classNames('beta-badge spacer-left', {
'is-muted': query.manual
})}>
{translate('beta')}
</span>
</>
)
},
{ key: 'manual', node: translate('onboarding.create_project.create_manually') }
]}
/>
)}

{displayManual || !hasAutoProvisioning || !identityProvider ? (
{query.manual || !hasAutoProvisioning || !identityProvider ? (
<ManualProjectCreate
currentUser={currentUser}
onProjectCreate={this.handleProjectCreate}

+ 3
- 4
server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx View File

@@ -23,7 +23,7 @@ import { Location } from 'history';
import { CreateProjectPage } from '../CreateProjectPage';
import { getIdentityProviders } from '../../../../api/users';
import { LoggedInUser } from '../../../../app/types';
import { click, waitAndUpdate } from '../../../../helpers/testUtils';
import { waitAndUpdate } from '../../../../helpers/testUtils';

jest.mock('../../../../api/users', () => ({
getIdentityProviders: jest.fn().mockResolvedValue({
@@ -70,12 +70,11 @@ it('should switch tabs', async () => {
});

await waitAndUpdate(wrapper);

expect(wrapper).toMatchSnapshot();

click(wrapper.find('.js-manual'));
wrapper.find('Tabs').prop<Function>('onChange')('manual');
expect(wrapper.find('Connect(ManualProjectCreate)').exists()).toBeTruthy();
click(wrapper.find('.js-auto'));
wrapper.find('Tabs').prop<Function>('onChange')('auto');
expect(wrapper.find('AutoProjectCreate').exists()).toBeTruthy();
});


+ 54
- 62
server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap View File

@@ -11,7 +11,7 @@ exports[`should render correctly 1`] = `
<div
className="sonarcloud page page-limited"
>
<div
<header
className="page-header"
>
<h1
@@ -19,7 +19,7 @@ exports[`should render correctly 1`] = `
>
onboarding.create_project.header
</h1>
</div>
</header>
<DeferredSpinner
timeout={100}
/>
@@ -38,7 +38,7 @@ exports[`should render correctly 2`] = `
<div
className="sonarcloud page page-limited"
>
<div
<header
className="page-header"
>
<h1
@@ -46,34 +46,30 @@ exports[`should render correctly 2`] = `
>
onboarding.create_project.header
</h1>
</div>
<ul
className="flex-tabs"
>
<li>
<a
className="js-auto selected"
href="#"
onClick={[Function]}
>
onboarding.create_project.select_repositories
<div
className="beta-badge spacer-left"
>
beta
</div>
</a>
</li>
<li>
<a
className="js-manual"
href="#"
onClick={[Function]}
>
onboarding.create_project.create_manually
</a>
</li>
</ul>
</header>
<Tabs
onChange={[Function]}
selected="auto"
tabs={
Array [
Object {
"key": "auto",
"node": <React.Fragment>
onboarding.create_project.select_repositories
<span
className="beta-badge spacer-left"
>
beta
</span>
</React.Fragment>,
},
Object {
"key": "manual",
"node": "onboarding.create_project.create_manually",
},
]
}
/>
<AutoProjectCreate
identityProvider={
Object {
@@ -100,7 +96,7 @@ exports[`should render with Manual creation only 1`] = `
<div
className="sonarcloud page page-limited"
>
<div
<header
className="page-header"
>
<h1
@@ -108,7 +104,7 @@ exports[`should render with Manual creation only 1`] = `
>
onboarding.create_project.header
</h1>
</div>
</header>
<Connect(ManualProjectCreate)
currentUser={
Object {
@@ -137,7 +133,7 @@ exports[`should switch tabs 1`] = `
<div
className="sonarcloud page page-limited"
>
<div
<header
className="page-header"
>
<h1
@@ -145,34 +141,30 @@ exports[`should switch tabs 1`] = `
>
onboarding.create_project.header
</h1>
</div>
<ul
className="flex-tabs"
>
<li>
<a
className="js-auto selected"
href="#"
onClick={[Function]}
>
onboarding.create_project.select_repositories
<div
className="beta-badge spacer-left"
>
beta
</div>
</a>
</li>
<li>
<a
className="js-manual"
href="#"
onClick={[Function]}
>
onboarding.create_project.create_manually
</a>
</li>
</ul>
</header>
<Tabs
onChange={[Function]}
selected="auto"
tabs={
Array [
Object {
"key": "auto",
"node": <React.Fragment>
onboarding.create_project.select_repositories
<span
className="beta-badge spacer-left"
>
beta
</span>
</React.Fragment>,
},
Object {
"key": "manual",
"node": "onboarding.create_project.create_manually",
},
]
}
/>
<AutoProjectCreate
identityProvider={
Object {

+ 2
- 1
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx View File

@@ -26,8 +26,9 @@ import OnboardingProjectIcon from '../../../components/icons-components/Onboardi
import OnboardingTeamIcon from '../../../components/icons-components/OnboardingTeamIcon';
import { Button, ResetButtonLink } from '../../../components/ui/buttons';
import { translate } from '../../../helpers/l10n';
import { CurrentUser, isLoggedIn } from '../../../app/types';
import { CurrentUser } from '../../../app/types';
import { getCurrentUser, Store } from '../../../store/rootReducer';
import { isLoggedIn } from '../../../helpers/users';
import '../styles.css';

interface OwnProps {

+ 2
- 1
server/sonar-web/src/main/js/apps/tutorials/projectOnboarding/ProjectOnboarding.tsx View File

@@ -27,11 +27,12 @@ import OrganizationStep from '../components/OrganizationStep';
import TokenStep from '../components/TokenStep';
import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
import { getCurrentUser, areThereCustomOrganizations, Store } from '../../../store/rootReducer';
import { CurrentUser, isLoggedIn } from '../../../app/types';
import { CurrentUser } from '../../../app/types';
import { ResetButtonLink } from '../../../components/ui/buttons';
import { getProjectUrl } from '../../../helpers/urls';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { isSonarCloud } from '../../../helpers/system';
import { isLoggedIn } from '../../../helpers/users';
import '../styles.css';

interface OwnProps {

+ 2
- 1
server/sonar-web/src/main/js/components/controls/HomePageSelect.tsx View File

@@ -22,10 +22,11 @@ import * as classNames from 'classnames';
import { connect } from 'react-redux';
import Tooltip from './Tooltip';
import HomeIcon from '../icons-components/HomeIcon';
import { CurrentUser, isLoggedIn, HomePage, isSameHomePage } from '../../app/types';
import { CurrentUser, HomePage, isSameHomePage } from '../../app/types';
import { translate } from '../../helpers/l10n';
import { getCurrentUser, Store } from '../../store/rootReducer';
import { setHomePage } from '../../store/users';
import { isLoggedIn } from '../../helpers/users';

interface StateProps {
currentUser: CurrentUser;

+ 60
- 0
server/sonar-web/src/main/js/components/controls/Tabs.css View File

@@ -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;
}

+ 77
- 0
server/sonar-web/src/main/js/components/controls/Tabs.tsx View File

@@ -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>
);
}
}

+ 75
- 0
server/sonar-web/src/main/js/components/controls/__tests__/Tabs-test.tsx View File

@@ -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();
});

+ 52
- 0
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap View File

@@ -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>
`;

+ 2
- 1
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx View File

@@ -29,8 +29,9 @@ import { searchUsers } from '../../../api/users';
import { translate } from '../../../helpers/l10n';
import { getCurrentUser, Store } from '../../../store/rootReducer';
import { DropdownOverlay } from '../../controls/Dropdown';
import { Issue, CurrentUser, isLoggedIn, OrganizationMember } from '../../../app/types';
import { Issue, CurrentUser, OrganizationMember } from '../../../app/types';
import { isSonarCloud } from '../../../helpers/system';
import { isLoggedIn } from '../../../helpers/users';

interface User {
avatar?: string;

+ 8
- 0
server/sonar-web/src/main/js/helpers/almIntegrations.ts View File

@@ -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');

+ 2
- 1
server/sonar-web/src/main/js/helpers/organizations.ts View File

@@ -17,7 +17,8 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Organization, isLoggedIn, OrganizationSubscription, CurrentUser } from '../app/types';
import { isLoggedIn } from './users';
import { Organization, OrganizationSubscription, CurrentUser } from '../app/types';

export function isPaidOrganization(organization: Organization | undefined): boolean {
return Boolean(organization && organization.subscription === OrganizationSubscription.Paid);

+ 31
- 0
server/sonar-web/src/main/js/helpers/users.ts View File

@@ -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;
}

+ 11
- 3
server/sonar-web/src/main/js/store/users.ts View File

@@ -21,16 +21,24 @@ import { uniq } from 'lodash';
import { Dispatch, combineReducers } from 'redux';
import { ActionType } from './utils/actions';
import * as api from '../api/users';
import { CurrentUser, HomePage, isLoggedIn, LoggedInUser } from '../app/types';
import { CurrentUser, HomePage, LoggedInUser } from '../app/types';
import { isLoggedIn } from '../helpers/users';

export function receiveCurrentUser(user: CurrentUser) {
return { type: 'RECEIVE_CURRENT_USER', user };
}

export function skipOnboarding() {
function skipOnboardingAction() {
return { type: 'SKIP_ONBOARDING' };
}

export function skipOnboarding() {
return (dispatch: Dispatch) =>
api
.skipOnboarding()
.then(() => dispatch(skipOnboardingAction()), () => dispatch(skipOnboardingAction()));
}

function setHomePageAction(homepage: HomePage) {
return { type: 'SET_HOMEPAGE', homepage };
}
@@ -48,7 +56,7 @@ export function setHomePage(homepage: HomePage) {

type Action =
| ActionType<typeof receiveCurrentUser, 'RECEIVE_CURRENT_USER'>
| ActionType<typeof skipOnboarding, 'SKIP_ONBOARDING'>
| ActionType<typeof skipOnboardingAction, 'SKIP_ONBOARDING'>
| ActionType<typeof setHomePageAction, 'SET_HOMEPAGE'>;

export interface State {

+ 11
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2730,7 +2730,7 @@ onboarding.create_project.select_repositories=Select repositories

onboarding.create_organization.page.header=Create Organization
onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects.{break}To analyze a private project you must subscribe your organization to a paid plan. From {price} a month. {more}
onboarding.create_organization.organization_name=Organization Name
onboarding.create_organization.organization_name=Key
onboarding.create_organization.organization_name.description=Up to 255 characters. All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). The display name can be specified in the additional info.
onboarding.create_organization.organization_name.error=The provided value doesn't match the expected format.
onboarding.create_organization.organization_name.taken=This name is already taken.
@@ -2746,6 +2746,16 @@ onboarding.create_organization.url=URL
onboarding.create_organization.url.error=The value must be a valid url.
onboarding.create_organization.description=Description
onboarding.create_organization.enter_org_details=Enter your organization details
onboarding.create_organization.create_manually=Create manually
onboarding.create_organization.import_organization.bitbucket=Import from BitBucket teams
onboarding.create_organization.import_organization.github=Import from GitHub organizations
onboarding.create_organization.import_organization_x=Import {avatar} {name} into SonarCloud organization
onboarding.create_organization.import_org_details=Import organization details
onboarding.create_organization.import_org_not_found=We were not able to find the requested organization, here are a few tips to help you troubleshoot the issue:
onboarding.create_organization.import_org_not_found.tips_1=You must be an administrator of the organization
onboarding.create_organization.import_org_not_found.tips_2=Try to uninstall and re-install the SonarCloud App (using the button bellow)
onboarding.create_organization.choose_organization_button.bitbucket=Choose a team on Bitbucket
onboarding.create_organization.choose_organization_button.github=Choose an organization on GitHub
onboarding.create_organization.enter_payment_details=Enter payment details
onboarding.create_organization.choose_plan=Choose a plan
onboarding.create_organization.enter_your_coupon=Enter your coupon

Loading…
Cancel
Save