]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17586 Allow project onboarding when multiple Gitlab integrations are configured
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Fri, 11 Nov 2022 15:12:19 +0000 (16:12 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 17 Nov 2022 20:03:07 +0000 (20:03 +0000)
23 files changed:
server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProject-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/constants.ts
server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenu.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/ProjectCreationMenu-test.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionBox.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBindingRenderer.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBindingRenderer-test.tsx.snap
server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts
new file mode 100644 (file)
index 0000000..d63bbf2
--- /dev/null
@@ -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);
+  };
+}
diff --git a/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts
new file mode 100644 (file)
index 0000000..9fb8b1b
--- /dev/null
@@ -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);
+  };
+}
index 7e4c9c0c007f165140a426f59f76deabf27ad894..22f27994a94276ece4565d59327b03c6ad60b8bf 100644 (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">
index abbd5af9d0584e021c5fd0ff9ecf40cda61822e8..926f2fe4f8962dc9b459d695e19fcbfca5e62129 100644 (file)
@@ -219,7 +219,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
             location={location}
             onProjectCreate={this.handleProjectCreate}
             router={router}
-            settings={gitlabSettings}
+            almInstances={gitlabSettings}
           />
         );
       }
index b51ade4a0e99d6250b8f0f9a878a19c768479e17..2c2c80ec3a86f7621b48828d53cefad39ffaf927 100644 (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}
       />
     );
   }
index 8dc36218a5a1c3162474c3b97d33e996baaeb299..668420dffe2458313e01eb9050ee4f68b50ac297 100644 (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}
           />
index 3462224c37d64d3e09ba40d0f882d76bb172d673..d3b501776c0a6e11d8a2085ed3bf585034ac3e1d 100644 (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}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProject-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProject-it.tsx
new file mode 100644 (file)
index 0000000..ca3c8f2
--- /dev/null
@@ -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 />);
+}
index 4504a6e091207879ed154ca5963899dcb64f13ea..4293a00e9360000a71227762135fadb8fea15a55 100644 (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}
     />
   );
index dd0773a03bfe18c8d17b579218e312439350c9d3..9cdb7d6aa0e8ed4290a4b8d74340b34668e02195 100644 (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}
     />
   );
index 51f82e784c58146d6f507afad4d05f07d238649b..5ab0fec34d5234925f98fb5332d1795dc76c1477 100644 (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.
index 1d7755f6e7acdaad86246ae929765b69c1c571ed..7d5f76c3837721c39b9b1c6a372959a79f621347 100644 (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>
index 577f859ef13ec2b9daf50d323aaecf64f66584a6..e6a39694a600f29e73d737455054be6b9126b620 100644 (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",
index 68e71143079fcfa86214ea9d7510645c3037b55c..0fcab57ab4d39f78bc57ca8c47b66292140e9f10 100644 (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]}
index 2072f4e88158ed64524ba794cf3f3b35b90625b6..d569b0fb97b5b022dec8114622602b0dc82e41b4 100644 (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"
index d47426c1831b2b1e4b773915acee00f5b7ff89cf..3c342c2804ecdcf4b1fcefe53f02e5606e2f76bc 100644 (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];
index 1204ca8ae508f3099b0c122f708c45838a195cd9..97abfd16f81a6fd83bfc998e88b05f687aa3ebd3 100644 (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;
index e3f2908bf9b928d592d0bed94ac84857a71a024d..fabc60c1c976cbeb6259b22de2b5ba3b39289852 100644 (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']> = {}) {
index 7a5b9780acf377b812a7857cbcac9cd4e0df9dde..ee27acc7694df10301d6a043e3bcf7137b6f3227 100644 (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">
index 5589c1e952718ee682c84aff430f207e848cf759..c0737d418ebb56e422dfd4c708ba2defa821f8d3 100644 (file)
  */
 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>
index 8e6dce1629531dc7eac1261c17b1bd898188b4c9..ba29cae6f90cb4fa3b4017fb3d8851041547a6df 100644 (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>
diff --git a/server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx b/server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx
new file mode 100644 (file)
index 0000000..dee6fae
--- /dev/null
@@ -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)}
+    />
+  );
+}
index ae40c64092df04d7f6a84f76ae68a972260a45d9..aa861ca230e3e02b72a53bc748f767217120b3ae 100644 (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