Browse Source

SONAR-17586 Allow project onboarding when multiple Gitlab integrations are configured

tags/9.8.0.63668
Revanshu Paliwal 1 year ago
parent
commit
317ada58ae
23 changed files with 603 additions and 199 deletions
  1. 86
    0
      server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts
  2. 44
    0
      server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts
  3. 30
    16
      server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx
  4. 1
    1
      server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
  5. 26
    19
      server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx
  6. 24
    5
      server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx
  7. 18
    8
      server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx
  8. 105
    0
      server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProject-it.tsx
  9. 2
    2
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx
  10. 9
    3
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx
  11. 2
    2
      server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx
  12. 1
    1
      server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
  13. 10
    1
      server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap
  14. 114
    6
      server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap
  15. 5
    5
      server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap
  16. 4
    0
      server/sonar-web/src/main/js/apps/create/project/constants.ts
  17. 6
    1
      server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenu.tsx
  18. 1
    1
      server/sonar-web/src/main/js/apps/projects/components/__tests__/ProjectCreationMenu-test.tsx
  19. 2
    1
      server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionBox.tsx
  20. 6
    32
      server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBindingRenderer.tsx
  21. 30
    94
      server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBindingRenderer-test.tsx.snap
  22. 75
    0
      server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx
  23. 2
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 86
- 0
server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts View File

@@ -0,0 +1,86 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { cloneDeep } from 'lodash';
import { mockGitlabProject } from '../../helpers/mocks/alm-integrations';
import { GitlabProject } from '../../types/alm-integration';
import {
checkPersonalAccessTokenIsValid,
getGitlabProjects,
setAlmPersonalAccessToken,
} from '../alm-integrations';

export default class AlmIntegrationsServiceMock {
almInstancePATMap: { [key: string]: boolean } = {};
gitlabProjects: GitlabProject[];
defaultAlmInstancePATMap: { [key: string]: boolean } = {
'conf-final-1': false,
'conf-final-2': true,
};

defaultGitlabProjects: GitlabProject[] = [
mockGitlabProject({
name: 'Gitlab project 1',
id: '1',
sqProjectKey: 'key',
sqProjectName: 'Gitlab project 1',
}),
mockGitlabProject({ name: 'Gitlab project 2', id: '2' }),
mockGitlabProject({ name: 'Gitlab project 3', id: '3' }),
];

constructor() {
this.almInstancePATMap = cloneDeep(this.defaultAlmInstancePATMap);
this.gitlabProjects = cloneDeep(this.defaultGitlabProjects);
(checkPersonalAccessTokenIsValid as jest.Mock).mockImplementation(
this.checkPersonalAccessTokenIsValid
);
(setAlmPersonalAccessToken as jest.Mock).mockImplementation(this.setAlmPersonalAccessToken);
(getGitlabProjects as jest.Mock).mockImplementation(this.getGitlabProjects);
}

checkPersonalAccessTokenIsValid = (conf: string) => {
return Promise.resolve({ status: this.almInstancePATMap[conf] });
};

setAlmPersonalAccessToken = (conf: string) => {
this.almInstancePATMap[conf] = true;
return Promise.resolve();
};

getGitlabProjects = () => {
return Promise.resolve({
projects: this.gitlabProjects,
projectsPaging: {
pageIndex: 1,
pageSize: 30,
total: 3,
},
});
};

setGitlabProjects(gitlabProjects: GitlabProject[]) {
this.gitlabProjects = gitlabProjects;
}

reset = () => {
this.almInstancePATMap = cloneDeep(this.defaultAlmInstancePATMap);
this.gitlabProjects = cloneDeep(this.defaultGitlabProjects);
};
}

+ 44
- 0
server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts View File

@@ -0,0 +1,44 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { cloneDeep } from 'lodash';
import { mockAlmSettingsInstance } from '../../helpers/mocks/alm-settings';
import { AlmKeys, AlmSettingsInstance } from '../../types/alm-settings';
import { getAlmSettings } from '../alm-settings';

export default class AlmSettingsServiceMock {
almSettings: AlmSettingsInstance[];
defaultSetting: AlmSettingsInstance[] = [
mockAlmSettingsInstance({ key: 'conf-final-1', alm: AlmKeys.GitLab }),
mockAlmSettingsInstance({ key: 'conf-final-2', alm: AlmKeys.GitLab }),
];

constructor() {
this.almSettings = cloneDeep(this.defaultSetting);
(getAlmSettings as jest.Mock).mockImplementation(this.getAlmSettingsHandler);
}

getAlmSettingsHandler = () => {
return Promise.resolve(this.almSettings);
};

reset = () => {
this.almSettings = cloneDeep(this.defaultSetting);
};
}

+ 30
- 16
server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx View File

@@ -28,6 +28,7 @@ import { translate, translateWithParameters } from '../../../helpers/l10n';
import { getBaseUrl } from '../../../helpers/system';
import { AlmKeys } from '../../../types/alm-settings';
import { AppState } from '../../../types/appstate';
import { ALLOWED_MULTIPLE_CONFIGS } from './constants';
import { CreateProjectModes } from './types';

export interface CreateProjectModeSelectionProps {
@@ -42,6 +43,32 @@ export interface CreateProjectModeSelectionProps {

const DEFAULT_ICON_SIZE = 50;

function getErrorMessage(
hasTooManyConfig: boolean,
hasConfig: boolean,
canAdmin: boolean | undefined,
alm: AlmKeys
) {
if (hasTooManyConfig) {
return translateWithParameters(
'onboarding.create_project.too_many_alm_instances_X',
translate('alm', alm)
);
} else if (!hasConfig) {
return canAdmin
? translate('onboarding.create_project.alm_not_configured.admin')
: translate('onboarding.create_project.alm_not_configured');
}
}

function getMode(
isBitbucketOption: boolean,
hasBitbucketCloudConf: boolean,
mode: CreateProjectModes
) {
return isBitbucketOption && hasBitbucketCloudConf ? CreateProjectModes.BitbucketCloud : mode;
}

function renderAlmOption(
props: CreateProjectModeSelectionProps,
alm: AlmKeys.Azure | AlmKeys.BitbucketServer | AlmKeys.GitHub | AlmKeys.GitLab,
@@ -61,7 +88,7 @@ function renderAlmOption(
? almCounts[AlmKeys.BitbucketServer] + almCounts[AlmKeys.BitbucketCloud]
: almCounts[alm];
const hasConfig = count > 0;
const hasTooManyConfig = count > 1;
const hasTooManyConfig = count > 1 && !ALLOWED_MULTIPLE_CONFIGS.includes(alm);
const disabled = loadingBindings || hasTooManyConfig || (!hasConfig && !canAdmin);

const onClick = () => {
@@ -73,23 +100,10 @@ function renderAlmOption(
return props.onConfigMode(alm);
}

return props.onSelectMode(
isBitbucketOption && hasBitbucketCloudConf ? CreateProjectModes.BitbucketCloud : mode
);
return props.onSelectMode(getMode(isBitbucketOption, hasBitbucketCloudConf, mode));
};

let errorMessage = '';

if (hasTooManyConfig) {
errorMessage = translateWithParameters(
'onboarding.create_project.too_many_alm_instances_X',
translate('alm', alm)
);
} else if (!hasConfig) {
errorMessage = canAdmin
? translate('onboarding.create_project.alm_not_configured.admin')
: translate('onboarding.create_project.alm_not_configured');
}
const errorMessage = getErrorMessage(hasTooManyConfig, hasConfig, canAdmin, alm);

return (
<div className="display-flex-column">

+ 1
- 1
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx View File

@@ -219,7 +219,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
location={location}
onProjectCreate={this.handleProjectCreate}
router={router}
settings={gitlabSettings}
almInstances={gitlabSettings}
/>
);
}

+ 26
- 19
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx View File

@@ -29,7 +29,7 @@ interface Props {
canAdmin: boolean;
loadingBindings: boolean;
onProjectCreate: (projectKey: string) => void;
settings: AlmSettingsInstance[];
almInstances: AlmSettingsInstance[];
location: Location;
router: Router;
}
@@ -43,7 +43,7 @@ interface State {
resetPat: boolean;
searching: boolean;
searchQuery: string;
settings?: AlmSettingsInstance;
selectedAlmInstance: AlmSettingsInstance;
showPersonalAccessTokenForm: boolean;
}

@@ -63,7 +63,7 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
showPersonalAccessTokenForm: true,
searching: false,
searchQuery: '',
settings: props.settings.length === 1 ? props.settings[0] : undefined,
selectedAlmInstance: props.almInstances[0],
};
}

@@ -72,11 +72,9 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
}

componentDidUpdate(prevProps: Props) {
if (prevProps.settings.length === 0 && this.props.settings.length > 0) {
this.setState(
{ settings: this.props.settings.length === 1 ? this.props.settings[0] : undefined },
() => this.fetchInitialData()
);
const { almInstances } = this.props;
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
this.setState({ selectedAlmInstance: almInstances[0] }, () => this.fetchInitialData());
}
}

@@ -115,14 +113,14 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
};

fetchProjects = async (pageIndex = 1, query?: string) => {
const { settings } = this.state;
if (!settings) {
const { selectedAlmInstance } = this.state;
if (!selectedAlmInstance) {
return Promise.resolve(undefined);
}

try {
return await getGitlabProjects({
almSetting: settings.key,
almSetting: selectedAlmInstance.key,
page: pageIndex,
pageSize: GITLAB_PROJECTS_PAGESIZE,
query,
@@ -133,15 +131,15 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
};

doImport = async (gitlabProjectId: string) => {
const { settings } = this.state;
const { selectedAlmInstance } = this.state;

if (!settings) {
if (!selectedAlmInstance) {
return Promise.resolve(undefined);
}

try {
return await importGitlabProject({
almSetting: settings.key,
almSetting: selectedAlmInstance.key,
gitlabProjectId,
});
} catch (_) {
@@ -172,7 +170,6 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
} = this.state;

const result = await this.fetchProjects(pageIndex + 1, searchQuery);

if (this.mounted) {
this.setState(({ projects = [], projectsPaging }) => ({
loadingMore: false,
@@ -186,7 +183,6 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
this.setState({ searching: true, searchQuery });

const result = await this.fetchProjects(1, searchQuery);

if (this.mounted) {
this.setState(({ projects, projectsPaging }) => ({
searching: false,
@@ -208,8 +204,17 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
await this.fetchInitialData();
};

onChangeConfig = (instance: AlmSettingsInstance) => {
this.setState({
selectedAlmInstance: instance,
showPersonalAccessTokenForm: true,
projects: undefined,
resetPat: false,
});
};

render() {
const { canAdmin, loadingBindings, location } = this.props;
const { loadingBindings, location, almInstances, canAdmin } = this.props;
const {
importingGitlabProjectId,
loading,
@@ -219,14 +224,15 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
resetPat,
searching,
searchQuery,
settings,
selectedAlmInstance,
showPersonalAccessTokenForm,
} = this.state;

return (
<GitlabProjectCreateRenderer
settings={settings}
canAdmin={canAdmin}
almInstances={almInstances}
selectedAlmInstance={selectedAlmInstance}
importingGitlabProjectId={importingGitlabProjectId}
loading={loading || loadingBindings}
loadingMore={loadingMore}
@@ -242,6 +248,7 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
showPersonalAccessTokenForm={
showPersonalAccessTokenForm || Boolean(location.query.resetPat)
}
onChangeConfig={this.onChangeConfig}
/>
);
}

+ 24
- 5
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import AlmSettingsInstanceSelector from '../../../components/devops-platform/AlmSettingsInstanceSelector';
import { translate } from '../../../helpers/l10n';
import { getBaseUrl } from '../../../helpers/system';
import { GitlabProject } from '../../../types/alm-integration';
@@ -42,8 +43,10 @@ export interface GitlabProjectCreateRendererProps {
resetPat: boolean;
searching: boolean;
searchQuery: string;
settings?: AlmSettingsInstance;
almInstances?: AlmSettingsInstance[];
selectedAlmInstance?: AlmSettingsInstance;
showPersonalAccessTokenForm?: boolean;
onChangeConfig: (instance: AlmSettingsInstance) => void;
}

export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
@@ -57,7 +60,8 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe
resetPat,
searching,
searchQuery,
settings,
selectedAlmInstance,
almInstances,
showPersonalAccessTokenForm,
} = props;

@@ -77,17 +81,32 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe
}
/>

{almInstances && almInstances.length > 1 && (
<div className="display-flex-column huge-spacer-bottom">
<label htmlFor="alm-config-selector" className="spacer-bottom">
{translate('alm.configuration.selector.label')}
</label>
<AlmSettingsInstanceSelector
instances={almInstances}
onChange={props.onChangeConfig}
initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
classNames="abs-width-400"
inputId="alm-config-selector"
/>
</div>
)}

{loading && <i className="spinner" />}

{!loading && !settings && (
{!loading && !selectedAlmInstance && (
<WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />
)}

{!loading &&
settings &&
selectedAlmInstance &&
(showPersonalAccessTokenForm ? (
<PersonalAccessTokenForm
almSetting={settings}
almSetting={selectedAlmInstance}
resetPat={resetPat}
onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
/>

+ 18
- 8
server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx View File

@@ -75,12 +75,26 @@ export default class PersonalAccessTokenForm extends React.PureComponent<Props,
};
}

async componentDidMount() {
componentDidMount() {
this.mounted = true;
this.checkPATAndUpdateView();
}

componentDidUpdate(prevProps: Props) {
if (this.props.almSetting !== prevProps.almSetting) {
this.checkPATAndUpdateView();
}
}

componentWillUnmount() {
this.mounted = false;
}

checkPATAndUpdateView = async () => {
const {
almSetting: { key },
resetPat,
} = this.props;
this.mounted = true;

// We don't need to check PAT if we want to reset
if (!resetPat) {
@@ -106,11 +120,7 @@ export default class PersonalAccessTokenForm extends React.PureComponent<Props,
}
}
}
}

componentWillUnmount() {
this.mounted = false;
}
};

handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({
@@ -379,7 +389,7 @@ export default class PersonalAccessTokenForm extends React.PureComponent<Props,
className={classNames('input-super-large', {
'is-invalid': isInvalid,
})}
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
value={password}
onChange={this.handlePasswordChange}

+ 105
- 0
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProject-it.tsx View File

@@ -0,0 +1,105 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import selectEvent from 'react-select-event';
import { byLabelText, byRole, byText } from 'testing-library-selector';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import CreateProjectPage from '../CreateProjectPage';

jest.mock('../../../../api/alm-integrations');
jest.mock('../../../../api/alm-settings');

let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;

const ui = {
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
personalAccessTokenInput: byRole('textbox', {
name: 'onboarding.create_project.enter_pat field_required',
}),
instanceSelector: byLabelText('alm.configuration.selector.label'),
};

beforeAll(() => {
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
});

afterEach(() => {
almIntegrationHandler.reset();
almSettingsHandler.reset();
});

describe('Gitlab onboarding page', () => {
it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
const user = userEvent.setup();
renderCreateProject();
expect(ui.gitlabCreateProjectButton.get()).toBeInTheDocument();

await user.click(ui.gitlabCreateProjectButton.get());
expect(screen.getByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
expect(screen.getByText('alm.configuration.selector.label')).toBeInTheDocument();

expect(screen.getByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
expect(screen.getByText('onboarding.create_project.pat_help.title')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();

await user.click(ui.personalAccessTokenInput.get());
await user.keyboard('secret');
await user.click(screen.getByRole('button', { name: 'save' }));

expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
expect(screen.getAllByText('onboarding.create_project.set_up')).toHaveLength(2);
expect(screen.getByText('onboarding.create_project.repository_imported')).toBeInTheDocument();
});

it('should show import project feature when PAT is already set', async () => {
const user = userEvent.setup();
renderCreateProject();
await act(async () => {
await user.click(ui.gitlabCreateProjectButton.get());
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);
});

expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
});

it('should show no result message when there are no projects', async () => {
const user = userEvent.setup();
almIntegrationHandler.setGitlabProjects([]);
renderCreateProject();
await act(async () => {
await user.click(ui.gitlabCreateProjectButton.get());
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);
});

expect(screen.getByText('onboarding.create_project.gitlab.no_projects')).toBeInTheDocument();
});
});

function renderCreateProject() {
renderApp('project/create', <CreateProjectPage />);
}

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx View File

@@ -154,7 +154,7 @@ it('should import', async () => {
});

it('should do nothing with missing settings', async () => {
const wrapper = shallowRender({ settings: [] });
const wrapper = shallowRender({ almInstances: [] });

await wrapper.instance().handleLoadMore();
await wrapper.instance().handleSearch('whatever');
@@ -204,7 +204,7 @@ function shallowRender(props: Partial<GitlabProjectCreate['props']> = {}) {
location={mockLocation()}
onProjectCreate={jest.fn()}
router={mockRouter()}
settings={[mockAlmSettingsInstance({ alm: AlmKeys.GitLab, key: almSettingKey })]}
almInstances={[mockAlmSettingsInstance({ alm: AlmKeys.GitLab, key: almSettingKey })]}
{...props}
/>
);

+ 9
- 3
server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx View File

@@ -27,8 +27,8 @@ import GitlabProjectCreateRenderer, {

it('should render correctly', () => {
expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
expect(shallowRender({ settings: undefined })).toMatchSnapshot('invalid settings');
expect(shallowRender({ canAdmin: true, settings: undefined })).toMatchSnapshot(
expect(shallowRender({ almInstances: undefined })).toMatchSnapshot('invalid settings');
expect(shallowRender({ almInstances: undefined })).toMatchSnapshot(
'invalid settings, admin user'
);
expect(shallowRender()).toMatchSnapshot('pat form');
@@ -47,13 +47,19 @@ function shallowRender(props: Partial<GitlabProjectCreateRendererProps> = {}) {
onLoadMore={jest.fn()}
onPersonalAccessTokenCreated={jest.fn()}
onSearch={jest.fn()}
onChangeConfig={jest.fn()}
projects={undefined}
projectsPaging={{ pageIndex: 1, pageSize: 30, total: 0 }}
searching={false}
searchQuery=""
resetPat={false}
showPersonalAccessTokenForm={true}
settings={mockAlmSettingsInstance({ alm: AlmKeys.GitLab })}
almInstances={[
mockAlmSettingsInstance({ alm: AlmKeys.GitLab }),
mockAlmSettingsInstance({ alm: AlmKeys.GitLab }),
mockAlmSettingsInstance({ alm: AlmKeys.GitHub }),
]}
selectedAlmInstance={mockAlmSettingsInstance({ alm: AlmKeys.GitLab })}
{...props}
/>
);

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx View File

@@ -97,7 +97,7 @@ it('should correctly handle form for bitbucket interactions', async () => {
// Submit button disabled by default.
expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true);

change(wrapper.find('#personal_access_token'), 'token');
change(wrapper.find('input#personal_access_token_validation'), 'token');
expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true);

// Submit button enabled if there's a value.
@@ -120,7 +120,7 @@ it('should show error when issue', async () => {

(checkPersonalAccessTokenIsValid as jest.Mock).mockRejectedValueOnce({});

change(wrapper.find('#personal_access_token'), 'token');
change(wrapper.find('input#personal_access_token_validation'), 'token');
change(wrapper.find('#username'), 'username');

// Expect correct calls to be made when submitting.

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

@@ -296,6 +296,7 @@ exports[`should render correctly for gitlab mode 1`] = `
id="create-project"
>
<GitlabProjectCreate
almInstances={Array []}
canAdmin={false}
loadingBindings={true}
location={
@@ -324,7 +325,6 @@ exports[`should render correctly for gitlab mode 1`] = `
"setRouteLeaveHook": [MockFunction],
}
}
settings={Array []}
/>
</div>
</Fragment>

+ 10
- 1
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap View File

@@ -2,9 +2,18 @@

exports[`should render correctly 1`] = `
<GitlabProjectCreateRenderer
almInstances={
Array [
Object {
"alm": "gitlab",
"key": "gitlab-setting",
},
]
}
canAdmin={false}
loading={false}
loadingMore={false}
onChangeConfig={[Function]}
onImport={[Function]}
onLoadMore={[Function]}
onPersonalAccessTokenCreated={[Function]}
@@ -19,7 +28,7 @@ exports[`should render correctly 1`] = `
resetPat={false}
searchQuery=""
searching={false}
settings={
selectedAlmInstance={
Object {
"alm": "gitlab",
"key": "gitlab-setting",

+ 114
- 6
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap View File

@@ -17,9 +17,15 @@ exports[`should render correctly: invalid settings 1`] = `
</span>
}
/>
<WrongBindingCountAlert
alm="gitlab"
canAdmin={false}
<PersonalAccessTokenForm
almSetting={
Object {
"alm": "gitlab",
"key": "key",
}
}
onPersonalAccessTokenCreated={[MockFunction]}
resetPat={false}
/>
</Fragment>
`;
@@ -41,9 +47,15 @@ exports[`should render correctly: invalid settings, admin user 1`] = `
</span>
}
/>
<WrongBindingCountAlert
alm="gitlab"
canAdmin={true}
<PersonalAccessTokenForm
almSetting={
Object {
"alm": "gitlab",
"key": "key",
}
}
onPersonalAccessTokenCreated={[MockFunction]}
resetPat={false}
/>
</Fragment>
`;
@@ -65,6 +77,38 @@ exports[`should render correctly: loading 1`] = `
</span>
}
/>
<div
className="display-flex-column huge-spacer-bottom"
>
<label
className="spacer-bottom"
htmlFor="alm-config-selector"
>
alm.configuration.selector.label
</label>
<AlmSettingsInstanceSelector
classNames="abs-width-400"
initialValue="key"
inputId="alm-config-selector"
instances={
Array [
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "github",
"key": "key",
},
]
}
onChange={[MockFunction]}
/>
</div>
<i
className="spinner"
/>
@@ -88,6 +132,38 @@ exports[`should render correctly: pat form 1`] = `
</span>
}
/>
<div
className="display-flex-column huge-spacer-bottom"
>
<label
className="spacer-bottom"
htmlFor="alm-config-selector"
>
alm.configuration.selector.label
</label>
<AlmSettingsInstanceSelector
classNames="abs-width-400"
initialValue="key"
inputId="alm-config-selector"
instances={
Array [
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "github",
"key": "key",
},
]
}
onChange={[MockFunction]}
/>
</div>
<PersonalAccessTokenForm
almSetting={
Object {
@@ -118,6 +194,38 @@ exports[`should render correctly: project selection form 1`] = `
</span>
}
/>
<div
className="display-flex-column huge-spacer-bottom"
>
<label
className="spacer-bottom"
htmlFor="alm-config-selector"
>
alm.configuration.selector.label
</label>
<AlmSettingsInstanceSelector
classNames="abs-width-400"
initialValue="key"
inputId="alm-config-selector"
instances={
Array [
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "gitlab",
"key": "key",
},
Object {
"alm": "github",
"key": "key",
},
]
}
onChange={[MockFunction]}
/>
</div>
<GitlabProjectSelectionForm
loadingMore={false}
onImport={[MockFunction]}

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

@@ -29,7 +29,7 @@ exports[`should render correctly: bitbucket 1`] = `
<input
autoFocus={true}
className="input-super-large is-invalid"
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
onChange={[Function]}
type="text"
@@ -180,7 +180,7 @@ exports[`should render correctly: bitbucket cloud 1`] = `
<input
autoFocus={false}
className="input-super-large is-invalid"
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
onChange={[Function]}
type="text"
@@ -321,7 +321,7 @@ exports[`should render correctly: gitlab 1`] = `
<input
autoFocus={true}
className="input-super-large is-invalid"
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
onChange={[Function]}
type="text"
@@ -431,7 +431,7 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = `
<input
autoFocus={true}
className="input-super-large is-invalid"
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
onChange={[Function]}
type="text"
@@ -566,7 +566,7 @@ exports[`should show error when issue: issue submitting token 1`] = `
<input
autoFocus={false}
className="input-super-large is-invalid"
id="personal_access_token"
id="personal_access_token_validation"
minLength={1}
onChange={[Function]}
type="text"

+ 4
- 0
server/sonar-web/src/main/js/apps/create/project/constants.ts View File

@@ -1,3 +1,5 @@
import { AlmKeys } from '../../../types/alm-settings';

/*
* SonarQube
* Copyright (C) 2009-2022 SonarSource SA
@@ -20,3 +22,5 @@
export const PROJECT_NAME_MAX_LEN = 255;

export const DEFAULT_BBS_PAGE_SIZE = 25;

export const ALLOWED_MULTIPLE_CONFIGS = [AlmKeys.GitLab];

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

@@ -31,6 +31,7 @@ import { hasGlobalPermission } from '../../../helpers/users';
import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
import { Permissions } from '../../../types/permissions';
import { LoggedInUser } from '../../../types/users';
import { ALLOWED_MULTIPLE_CONFIGS } from '../../create/project/constants';
import ProjectCreationMenuItem from './ProjectCreationMenuItem';

interface Props {
@@ -90,7 +91,7 @@ export class ProjectCreationMenu extends React.PureComponent<Props, State> {
currentAlmSettings = almSettings.filter((s) => s.alm === key);
}
return (
currentAlmSettings.length === 1 &&
this.configLengthChecker(key, currentAlmSettings.length) &&
key === currentAlmSettings[0].alm &&
this.almSettingIsValid(currentAlmSettings[0])
);
@@ -103,6 +104,10 @@ export class ProjectCreationMenu extends React.PureComponent<Props, State> {
}
};

configLengthChecker = (key: AlmKeys, length: number) => {
return ALLOWED_MULTIPLE_CONFIGS.includes(key) ? length > 0 : length === 1;
};

render() {
const { className, currentUser } = this.props;
const { boundAlms } = this.state;

+ 1
- 1
server/sonar-web/src/main/js/apps/projects/components/__tests__/ProjectCreationMenu-test.tsx View File

@@ -121,7 +121,7 @@ it('should filter alm bindings appropriately', async () => {

wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper.state().boundAlms).toEqual([]);
expect(wrapper.state().boundAlms).toEqual([AlmKeys.GitLab]);
});

function shallowRender(overrides: Partial<ProjectCreationMenu['props']> = {}) {

+ 2
- 1
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionBox.tsx View File

@@ -38,6 +38,7 @@ import {
AlmSettingsBindingStatusType,
} from '../../../../types/alm-settings';
import { EditionKey } from '../../../../types/editions';
import { ALLOWED_MULTIPLE_CONFIGS } from '../../../create/project/constants';

export interface AlmBindingDefinitionBoxProps {
alm: AlmKeys;
@@ -110,7 +111,7 @@ function getImportFeatureStatus(
multipleDefinitions: boolean,
type: AlmSettingsBindingStatusType.Success | AlmSettingsBindingStatusType.Failure
) {
if (multipleDefinitions) {
if (multipleDefinitions && !ALLOWED_MULTIPLE_CONFIGS.includes(alm)) {
return (
<div className="display-inline-flex-center">
<strong className="spacer-left">

+ 6
- 32
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBindingRenderer.tsx View File

@@ -19,10 +19,9 @@
*/
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { components, OptionProps, SingleValueProps } from 'react-select';
import Link from '../../../../components/common/Link';
import { Button, SubmitButton } from '../../../../components/controls/buttons';
import Select from '../../../../components/controls/Select';
import AlmSettingsInstanceSelector from '../../../../components/devops-platform/AlmSettingsInstanceSelector';
import AlertSuccessIcon from '../../../../components/icons/AlertSuccessIcon';
import { Alert } from '../../../../components/ui/Alert';
import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
@@ -57,25 +56,6 @@ export interface PRDecorationBindingRendererProps {
isSysAdmin: boolean;
}

function optionRenderer(props: OptionProps<AlmSettingsInstance, false>) {
return <components.Option {...props}>{customOptions(props.data)}</components.Option>;
}

function singleValueRenderer(props: SingleValueProps<AlmSettingsInstance>) {
return <components.SingleValue {...props}>{customOptions(props.data)}</components.SingleValue>;
}

function customOptions(instance: AlmSettingsInstance) {
return instance.url ? (
<>
<span>{instance.key} — </span>
<span className="text-muted">{instance.url}</span>
</>
) : (
<span>{instance.key}</span>
);
}

export default function PRDecorationBindingRenderer(props: PRDecorationBindingRendererProps) {
const {
formData,
@@ -151,18 +131,12 @@ export default function PRDecorationBindingRenderer(props: PRDecorationBindingRe
</div>
</div>
<div className="settings-definition-right">
<Select
inputId="name"
className="abs-width-400 big-spacer-top it__configuration-name-select"
isClearable={false}
isSearchable={false}
options={instances}
<AlmSettingsInstanceSelector
instances={instances}
onChange={(instance: AlmSettingsInstance) => props.onFieldChange('key', instance.key)}
components={{
Option: optionRenderer,
SingleValue: singleValueRenderer,
}}
value={instances.filter((instance) => instance.key === formData.key)}
initialValue={formData.key}
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
inputId="name"
/>
</div>
</div>

+ 30
- 94
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBindingRenderer-test.tsx.snap View File

@@ -129,19 +129,11 @@ exports[`should render correctly: when there are configuration errors (admin use
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue="i1"
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -164,15 +156,7 @@ exports[`should render correctly: when there are configuration errors (admin use
},
]
}
value={
Array [
Object {
"alm": "github",
"key": "i1",
"url": "http://github.enterprise.com",
},
]
}
onChange={[Function]}
/>
</div>
</div>
@@ -329,19 +313,11 @@ exports[`should render correctly: when there are configuration errors (admin use
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue=""
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -364,7 +340,7 @@ exports[`should render correctly: when there are configuration errors (admin use
},
]
}
value={Array []}
onChange={[Function]}
/>
</div>
</div>
@@ -467,19 +443,11 @@ exports[`should render correctly: when there are configuration errors (non-admin
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue=""
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -502,7 +470,7 @@ exports[`should render correctly: when there are configuration errors (non-admin
},
]
}
value={Array []}
onChange={[Function]}
/>
</div>
</div>
@@ -608,19 +576,11 @@ exports[`should render correctly: with a single ALM instance 1`] = `
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue=""
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -629,7 +589,7 @@ exports[`should render correctly: with a single ALM instance 1`] = `
},
]
}
value={Array []}
onChange={[Function]}
/>
</div>
</div>
@@ -686,19 +646,11 @@ exports[`should render correctly: with a valid and saved form 1`] = `
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue="i1"
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -721,15 +673,7 @@ exports[`should render correctly: with a valid and saved form 1`] = `
},
]
}
value={
Array [
Object {
"alm": "github",
"key": "i1",
"url": "http://github.enterprise.com",
},
]
}
onChange={[Function]}
/>
</div>
</div>
@@ -848,19 +792,11 @@ exports[`should render correctly: with an empty form 1`] = `
<div
className="settings-definition-right"
>
<Select
className="abs-width-400 big-spacer-top it__configuration-name-select"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
<AlmSettingsInstanceSelector
classNames="abs-width-400 big-spacer-top it__configuration-name-select"
initialValue=""
inputId="name"
isClearable={false}
isSearchable={false}
onChange={[Function]}
options={
instances={
Array [
Object {
"alm": "github",
@@ -883,7 +819,7 @@ exports[`should render correctly: with an empty form 1`] = `
},
]
}
value={Array []}
onChange={[Function]}
/>
</div>
</div>

+ 75
- 0
server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx View File

@@ -0,0 +1,75 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { components, OptionProps, SingleValueProps } from 'react-select';
import { AlmSettingsInstance } from '../../types/alm-settings';
import Select from '../controls/Select';

function optionRenderer(props: OptionProps<AlmSettingsInstance, false>) {
return <components.Option {...props}>{customOptions(props.data)}</components.Option>;
}

function singleValueRenderer(props: SingleValueProps<AlmSettingsInstance>) {
return <components.SingleValue {...props}>{customOptions(props.data)}</components.SingleValue>;
}

function customOptions(instance: AlmSettingsInstance) {
return instance.url ? (
<>
<span>{instance.key} — </span>
<span className="text-muted">{instance.url}</span>
</>
) : (
<span>{instance.key}</span>
);
}

interface Props {
instances: AlmSettingsInstance[];
initialValue?: string;
onChange: (instance: AlmSettingsInstance) => void;
classNames: string;
inputId: string;
}

export default function AlmSettingsInstanceSelector(props: Props) {
const { instances, initialValue, classNames, inputId } = props;

return (
<Select
inputId={inputId}
className={classNames}
isClearable={false}
isSearchable={false}
options={instances}
onChange={(inst) => {
if (inst) {
props.onChange(inst);
}
}}
components={{
Option: optionRenderer,
SingleValue: singleValueRenderer,
}}
getOptionValue={(opt) => opt.key}
value={instances.find((inst) => inst.key === initialValue)}
/>
);
}

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

@@ -389,6 +389,7 @@ alm.github=GitHub
alm.github.short=GitHub
alm.gitlab=GitLab
alm.gitlab.short=GitLab
alm.configuration.selector.label=What DevOps platform do you want to import project from?

#------------------------------------------------------------------------------
#
@@ -3598,7 +3599,7 @@ onboarding.create_project.github.warning.message_admin=Please make sure the GitH
onboarding.create_project.github.warning.message_admin.link=DevOps Platform integration settings
onboarding.create_project.github.no_orgs=We couldn't load any organizations with your key. Contact an administrator.
onboarding.create_project.github.no_orgs_admin=We couldn't load any organizations. Make sure the GitHub App is installed in at least one organization and check the GitHub instance configuration in the {link}.
onboarding.create_project.gitlab.title=Which GitLab project do you want to set up?
onboarding.create_project.gitlab.title=Gitlab project onboarding
onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}.
onboarding.create_project.gitlab.link=See on GitLab


Loading…
Cancel
Save