]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21822 Add monorepo setup for GitHub
authorAmbroise C <ambroise.christea@sonarsource.com>
Tue, 19 Mar 2024 10:47:48 +0000 (11:47 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 10 Apr 2024 20:02:55 +0000 (20:02 +0000)
49 files changed:
server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/src/main/js/api/dop-translation.ts
server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts
server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/Github/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx
server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx
server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx
server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/constants.ts
server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/types.ts
server/sonar-web/src/main/js/apps/create/project/utils.ts
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx
server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx
server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx
server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx
server/sonar-web/src/main/js/queries/import-projects.ts
server/sonar-web/src/main/js/types/dop-translation.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx b/server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx
new file mode 100644 (file)
index 0000000..6eee2ba
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers';
+import { CustomIcon, IconProps } from './Icon';
+
+export function AddNewIcon({ fill = 'currentColor', ...iconProps }: Readonly<IconProps>) {
+  const theme = useTheme();
+
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M8 0c-.55228 0-1 .44771-1 1v6H1c-.55229 0-1 .44771-1 1 0 .55228.44771 1 1 1h6v6c0 .5523.44772 1 1 1 .55229 0 1-.4477 1-1V9h6c.5523 0 1-.44771 1-1 0-.55228-.4477-1-1-1H9V1c0-.55229-.44771-1-1-1Z"
+        fill={themeColor(fill)({ theme })}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
index 1ab42332abc89e8ee46b343b126ad5d4e20f94b9..caeb1088b5aaa17bdd248bc306090d67924310e0 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+export { AddNewIcon } from './AddNewIcon';
 export { BranchIcon } from './BranchIcon';
 export { BugIcon } from './BugIcon';
 export { CalendarIcon } from './CalendarIcon';
index 393fb86da32b2b5eb6123ee088696aa6b7e052f5..9cc4e4827393640b08f9a357d7cec5a7a70764a1 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import axios from 'axios';
+import { BoundProject, DopSetting } from '../types/dop-translation';
+import { Paging } from '../types/types';
 
 const DOP_TRANSLATION_PATH = '/api/v2/dop-translation';
+const BOUND_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`;
+const DOP_SETTINGS_PATH = `${DOP_TRANSLATION_PATH}/dop-settings`;
 
-// Imported projects
-
-const IMPORTED_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`;
+export function createBoundProject(data: BoundProject) {
+  return axios.post(BOUND_PROJECTS_PATH, data);
+}
 
-export function createImportedProjects(data: {
-  devOpsPlatformSettingId: string;
-  monorepo: boolean;
-  newCodeDefinitionType?: string;
-  newCodeDefinitionValue?: string;
-  projectKey: string;
-  projectName: string;
-  repositoryIdentifier: string;
-}) {
-  return axios.post(IMPORTED_PROJECTS_PATH, data);
+export function getDopSettings() {
+  return axios.get<{ paging: Paging; dopSettings: DopSetting[] }>(DOP_SETTINGS_PATH);
 }
index 8f0404dda49497bca9930752137550fb27b190b2..9c3e794806608d09e9a4c4dbb94787c648543f40 100644 (file)
@@ -37,6 +37,7 @@ import {
   ComponentRaw,
   GetTreeParams,
   changeKey,
+  doesComponentExists,
   getBreadcrumbs,
   getChildren,
   getComponentData,
@@ -106,6 +107,7 @@ export default class ComponentsServiceMock {
     jest.mocked(setProjectTags).mockImplementation(this.handleSetProjectTags);
     jest.mocked(setApplicationTags).mockImplementation(this.handleSetApplicationTags);
     jest.mocked(searchProjects).mockImplementation(this.handleSearchProjects);
+    jest.mocked(doesComponentExists).mockImplementation(this.handleDoesComponentExists);
   }
 
   handleSearchProjects: typeof searchProjects = (data) => {
@@ -420,6 +422,11 @@ export default class ComponentsServiceMock {
     return this.reply();
   };
 
+  handleDoesComponentExists: typeof doesComponentExists = ({ component }) => {
+    const exists = this.components.some(({ component: { key } }) => key === component);
+    return this.reply(exists);
+  };
+
   reply<T>(): Promise<void>;
   reply<T>(response: T): Promise<T>;
   reply<T>(response?: T): Promise<T | void> {
diff --git a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts
new file mode 100644 (file)
index 0000000..1393c84
--- /dev/null
@@ -0,0 +1,123 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { mockPaging } from '../../helpers/testMocks';
+import { AlmKeys } from '../../types/alm-settings';
+import { BoundProject, DopSetting } from '../../types/dop-translation';
+import { createBoundProject, getDopSettings } from '../dop-translation';
+import { mockDopSetting } from './data/dop-translation';
+
+jest.mock('../dop-translation');
+
+const defaultDopSettings = [
+  mockDopSetting({ key: 'conf-final-1', type: AlmKeys.GitLab }),
+  mockDopSetting({ key: 'conf-final-2', type: AlmKeys.GitLab }),
+  mockDopSetting({ key: 'conf-github-1', type: AlmKeys.GitHub, url: 'http://url' }),
+  mockDopSetting({ key: 'conf-github-2', type: AlmKeys.GitHub, url: 'http://url' }),
+  mockDopSetting({ key: 'conf-github-3', type: AlmKeys.GitHub, url: 'javascript://url' }),
+  mockDopSetting({ key: 'conf-azure-1', type: AlmKeys.Azure, url: 'url' }),
+  mockDopSetting({ key: 'conf-azure-2', type: AlmKeys.Azure, url: 'url' }),
+  mockDopSetting({
+    key: 'conf-bitbucketcloud-1',
+    type: AlmKeys.BitbucketCloud,
+    url: 'url',
+  }),
+  mockDopSetting({
+    key: 'conf-bitbucketcloud-2',
+    type: AlmKeys.BitbucketCloud,
+    url: 'url',
+  }),
+  mockDopSetting({
+    key: 'conf-bitbucketserver-1',
+    type: AlmKeys.BitbucketServer,
+    url: 'url',
+  }),
+  mockDopSetting({
+    key: 'conf-bitbucketserver-2',
+    type: AlmKeys.BitbucketServer,
+    url: 'url',
+  }),
+  mockDopSetting(),
+  mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }),
+];
+
+export default class DopTranslationServiceMock {
+  boundProjects: BoundProject[] = [];
+  dopSettings: DopSetting[] = [
+    mockDopSetting({ key: 'conf-final-1', type: AlmKeys.GitLab }),
+    mockDopSetting({ key: 'conf-final-2', type: AlmKeys.GitLab }),
+    mockDopSetting({ key: 'conf-github-1', type: AlmKeys.GitHub, url: 'http://url' }),
+    mockDopSetting({ key: 'conf-github-2', type: AlmKeys.GitHub, url: 'http://url' }),
+    mockDopSetting({ key: 'conf-github-3', type: AlmKeys.GitHub, url: 'javascript://url' }),
+    mockDopSetting({ key: 'conf-azure-1', type: AlmKeys.Azure, url: 'url' }),
+    mockDopSetting({ key: 'conf-azure-2', type: AlmKeys.Azure, url: 'url' }),
+    mockDopSetting({
+      key: 'conf-bitbucketcloud-1',
+      type: AlmKeys.BitbucketCloud,
+      url: 'url',
+    }),
+    mockDopSetting({
+      key: 'conf-bitbucketcloud-2',
+      type: AlmKeys.BitbucketCloud,
+      url: 'url',
+    }),
+    mockDopSetting({
+      key: 'conf-bitbucketserver-1',
+      type: AlmKeys.BitbucketServer,
+      url: 'url',
+    }),
+    mockDopSetting({
+      key: 'conf-bitbucketserver-2',
+      type: AlmKeys.BitbucketServer,
+      url: 'url',
+    }),
+    mockDopSetting(),
+    mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }),
+  ];
+
+  constructor() {
+    jest.mocked(createBoundProject).mockImplementation(this.createBoundProject);
+    jest.mocked(getDopSettings).mockImplementation(this.getDopSettings);
+  }
+
+  createBoundProject: typeof createBoundProject = (data) => {
+    this.boundProjects.push(data);
+    return Promise.resolve({});
+  };
+
+  getDopSettings = () => {
+    const total = this.getDopSettings.length;
+    return Promise.resolve({
+      dopSettings: this.dopSettings,
+      paging: mockPaging({ pageSize: total, total }),
+    });
+  };
+
+  removeDopTypeFromSettings = (type: AlmKeys) => {
+    this.dopSettings = cloneDeep(defaultDopSettings).filter(
+      (dopSetting) => dopSetting.type !== type,
+    );
+  };
+
+  reset() {
+    this.boundProjects = [];
+    this.dopSettings = cloneDeep(defaultDopSettings);
+  }
+}
diff --git a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts
new file mode 100644 (file)
index 0000000..bc8a5d9
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+
+/* eslint-disable local-rules/use-metrickey-enum */
+
+import { AlmKeys } from '../../../types/alm-settings';
+import { DopSetting } from '../../../types/dop-translation';
+
+export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting {
+  return {
+    id: overrides?.id ?? overrides?.key ?? 'dop-setting-test-id',
+    key: 'Test/DopSetting',
+    type: AlmKeys.GitHub,
+    url: 'https://github.com',
+    ...overrides,
+  };
+}
index 5d1e48b42c0a69cdd37cc84a404bb1a8dc940f9a..118f69405ec252c88bebab04d9de48df090c4396 100644 (file)
@@ -213,6 +213,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
       this.props.onProjectSetupDone({
         creationMode: CreateProjectModes.AzureDevOps,
         almSetting: selectedAlmInstance.key,
+        monorepo: false,
         projects: [
           {
             projectName: selectedRepository.projectName,
index c35ba76a56009b3ada44ad879ec61d4f90fdc8d2..958473b2c7e961e25057ca65e50791e17f0c27e9 100644 (file)
@@ -198,6 +198,7 @@ export default class BitbucketCloudProjectCreate extends React.PureComponent<Pro
       this.props.onProjectSetupDone({
         creationMode: CreateProjectModes.BitbucketCloud,
         almSetting: selectedAlmInstance.key,
+        monorepo: false,
         projects: [
           {
             repositorySlug,
index ad64bf13476cca637abbe447236871539e46fde3..d4670719f414bf9f964c3a1888d4d8d8aadf6765 100644 (file)
@@ -189,6 +189,7 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S
       this.props.onProjectSetupDone({
         creationMode: CreateProjectModes.BitbucketServer,
         almSetting: selectedAlmInstance.key,
+        monorepo: false,
         projects: [
           {
             projectKey: selectedRepository.projectKey,
index 33921b2e60d1f3cb011d199838318480e8d85dd2..7b5ad35ffa87485e696cf0a89bc35a9a6c0f88b8 100644 (file)
@@ -21,7 +21,7 @@ import classNames from 'classnames';
 import { LargeCenteredLayout } from 'design-system';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
-import { getAlmSettings } from '../../../api/alm-settings';
+import { getDopSettings } from '../../../api/dop-translation';
 import withAppStateContext from '../../../app/components/app-state/withAppStateContext';
 import withAvailableFeatures, {
   WithAvailableFeaturesProps,
@@ -31,6 +31,7 @@ import { Location, Router, withRouter } from '../../../components/hoc/withRouter
 import { translate } from '../../../helpers/l10n';
 import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import { AppState } from '../../../types/appstate';
+import { DopSetting } from '../../../types/dop-translation';
 import { Feature } from '../../../types/features';
 import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
 import AzureProjectCreate from './Azure/AzureProjectCreate';
@@ -53,7 +54,7 @@ interface State {
   azureSettings: AlmSettingsInstance[];
   bitbucketSettings: AlmSettingsInstance[];
   bitbucketCloudSettings: AlmSettingsInstance[];
-  githubSettings: AlmSettingsInstance[];
+  githubSettings: DopSetting[];
   gitlabSettings: AlmSettingsInstance[];
   loading: boolean;
   creatingAlmDefinition?: AlmKeys;
@@ -73,6 +74,7 @@ export type ImportProjectParam =
   | {
       creationMode: CreateProjectModes.AzureDevOps;
       almSetting: string;
+      monorepo: false;
       projects: {
         projectName: string;
         repositoryName: string;
@@ -81,6 +83,7 @@ export type ImportProjectParam =
   | {
       creationMode: CreateProjectModes.BitbucketCloud;
       almSetting: string;
+      monorepo: false;
       projects: {
         repositorySlug: string;
       }[];
@@ -88,6 +91,7 @@ export type ImportProjectParam =
   | {
       creationMode: CreateProjectModes.BitbucketServer;
       almSetting: string;
+      monorepo: false;
       projects: {
         repositorySlug: string;
         projectKey: string;
@@ -96,6 +100,7 @@ export type ImportProjectParam =
   | {
       creationMode: CreateProjectModes.GitHub;
       almSetting: string;
+      monorepo: false;
       projects: {
         repositoryKey: string;
       }[];
@@ -103,12 +108,14 @@ export type ImportProjectParam =
   | {
       creationMode: CreateProjectModes.GitLab;
       almSetting: string;
+      monorepo: false;
       projects: {
         gitlabProjectId: string;
       }[];
     }
   | {
       creationMode: CreateProjectModes.Manual;
+      monorepo: false;
       projects: {
         project: string;
         name: string;
@@ -116,9 +123,9 @@ export type ImportProjectParam =
       }[];
     }
   | {
-      creationMode: CreateProjectModes.Monorepo;
+      creationMode: CreateProjectModes;
       devOpsPlatformSettingId: string;
-      monorepo: boolean;
+      monorepo: true;
       projects: {
         projectKey: string;
         projectName: string;
@@ -146,6 +153,14 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
     this.fetchAlmBindings();
   }
 
+  componentDidUpdate(prevProps: CreateProjectPageProps) {
+    const { location } = this.props;
+
+    if (location.query.mono !== prevProps.location.query.mono) {
+      this.fetchAlmBindings();
+    }
+  }
+
   componentWillUnmount() {
     this.mounted = false;
   }
@@ -153,6 +168,15 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
   cleanQueryParameters() {
     const { location, router } = this.props;
 
+    const isMonorepoSupported = this.props.hasFeature(Feature.MonoRepositoryPullRequestDecoration);
+
+    if (location.query?.mono === 'true' && !isMonorepoSupported) {
+      // Timeout is required to force the refresh of the URL
+      setTimeout(() => {
+        location.query.mono = undefined;
+        router.replace(location);
+      }, 0);
+    }
     if (location.query?.setncd === 'true') {
       // Timeout is required to force the refresh of the URL
       setTimeout(() => {
@@ -164,23 +188,28 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
 
   fetchAlmBindings = () => {
     this.setState({ loading: true });
-    return getAlmSettings()
-      .then((almSettings) => {
-        if (this.mounted) {
-          this.setState({
-            azureSettings: almSettings.filter((s) => s.alm === AlmKeys.Azure),
-            bitbucketSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketServer),
-            bitbucketCloudSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketCloud),
-            githubSettings: almSettings.filter((s) => s.alm === AlmKeys.GitHub),
-            gitlabSettings: almSettings.filter((s) => s.alm === AlmKeys.GitLab),
-            loading: false,
-          });
-        }
+
+    return getDopSettings()
+      .then(({ dopSettings }) => {
+        this.setState({
+          azureSettings: dopSettings
+            .filter(({ type }) => type === AlmKeys.Azure)
+            .map(({ key, type, url }) => ({ alm: type, key, url })),
+          bitbucketSettings: dopSettings
+            .filter(({ type }) => type === AlmKeys.BitbucketServer)
+            .map(({ key, type, url }) => ({ alm: type, key, url })),
+          bitbucketCloudSettings: dopSettings
+            .filter(({ type }) => type === AlmKeys.BitbucketCloud)
+            .map(({ key, type, url }) => ({ alm: type, key, url })),
+          githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub),
+          gitlabSettings: dopSettings
+            .filter(({ type }) => type === AlmKeys.GitLab)
+            .map(({ key, type, url }) => ({ alm: type, key, url })),
+          loading: false,
+        });
       })
       .catch(() => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
+        this.setState({ loading: false });
       });
   };
 
@@ -285,11 +314,9 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
         return (
           <GitHubProjectCreate
             canAdmin={!!canAdmin}
-            loadingBindings={loading}
-            location={location}
+            isLoadingBindings={loading}
             onProjectSetupDone={this.handleProjectSetupDone}
-            router={router}
-            almInstances={githubSettings}
+            dopSettings={githubSettings}
           />
         );
       }
index 3e8ba0ba017e53ac513e1ea506dc266b5dd41f3c..529879f1a5b7751ab5e9212229c978f8cf03b52a 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { debounce } from 'lodash';
-import * as React from 'react';
-import { isWebUri } from 'valid-url';
-import {
-  getGithubClientId,
-  getGithubOrganizations,
-  getGithubRepositories,
-} from '../../../../api/alm-integrations';
-import { Location, Router } from '../../../../components/hoc/withRouter';
-import { getHostUrl } from '../../../../helpers/urls';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { getGithubOrganizations, getGithubRepositories } from '../../../../api/alm-integrations';
+import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
+import { LabelValueSelectOption } from '../../../../helpers/search';
 import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { DopSetting } from '../../../../types/dop-translation';
 import { Paging } from '../../../../types/types';
 import { ImportProjectParam } from '../CreateProjectPage';
+import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
 import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
+import { redirectToGithub } from './utils';
 
 interface Props {
   canAdmin: boolean;
-  loadingBindings: boolean;
+  isLoadingBindings: boolean;
   onProjectSetupDone: (importProjects: ImportProjectParam) => void;
-  almInstances: AlmSettingsInstance[];
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  error: boolean;
-  loadingOrganizations: boolean;
-  loadingRepositories: boolean;
-  organizations: GithubOrganization[];
-  repositoryPaging: Paging;
-  repositories: GithubRepository[];
-  searchQuery: string;
-  selectedOrganization?: GithubOrganization;
-  selectedAlmInstance?: AlmSettingsInstance;
+  dopSettings: DopSetting[];
 }
 
 const REPOSITORY_PAGE_SIZE = 50;
+const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250;
+
+export default function GitHubProjectCreate(props: Readonly<Props>) {
+  const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props;
+
+  const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
+
+  const [isInError, setIsInError] = useState(false);
+  const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
+  const [isLoadingRepositories, setIsLoadingRepositories] = useState(false);
+  const [organizations, setOrganizations] = useState<GithubOrganization[]>([]);
+  const [repositories, setRepositories] = useState<GithubRepository[]>([]);
+  const [repositoryPaging, setRepositoryPaging] = useState<Paging>({
+    pageSize: REPOSITORY_PAGE_SIZE,
+    total: 0,
+    pageIndex: 1,
+  });
+  const [searchQuery, setSearchQuery] = useState('');
+  const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>();
+  const [selectedOrganization, setSelectedOrganization] = useState<GithubOrganization>();
+  const [selectedRepository, setSelectedRepository] = useState<GithubRepository>();
+
+  const location = useLocation();
+  const router = useRouter();
+
+  const isMonorepoSetup = location.query?.mono === 'true';
+  const hasDopSettings = Boolean(dopSettings?.length);
+  const organizationOptions = useMemo(() => {
+    return organizations.map(transformToOption);
+  }, [organizations]);
+  const repositoryOptions = useMemo(() => {
+    return repositories.map(transformToOption);
+  }, [repositories]);
+
+  const fetchRepositories = useCallback(
+    async (params: { organizationKey: string; page?: number; query?: string }) => {
+      const { organizationKey, page = 1, query } = params;
+
+      if (selectedDopSetting === undefined) {
+        setIsInError(true);
+        return;
+      }
 
-export default class GitHubProjectCreate extends React.Component<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      error: false,
-      loadingOrganizations: true,
-      loadingRepositories: false,
-      organizations: [],
-      repositories: [],
-      repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 },
-      searchQuery: '',
-      selectedAlmInstance: this.getInitialSelectedAlmInstance(),
-    };
-
-    this.triggerSearch = debounce(this.triggerSearch, 250);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.initialize();
-  }
+      setIsLoadingRepositories(true);
 
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () => {
-        this.initialize().catch(() => {
-          /* noop */
+      try {
+        const { paging, repositories } = await getGithubRepositories({
+          almSetting: selectedDopSetting.key,
+          organization: organizationKey,
+          pageSize: REPOSITORY_PAGE_SIZE,
+          page,
+          query,
         });
-      });
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
 
-  getInitialSelectedAlmInstance() {
-    const {
-      location: {
-        query: { almInstance: selectedAlmInstanceKey },
-      },
-      almInstances,
-    } = this.props;
-    const selectedAlmInstance = almInstances.find(
-      (instance) => instance.key === selectedAlmInstanceKey,
-    );
-    if (selectedAlmInstance) {
-      return selectedAlmInstance;
-    }
-    return this.props.almInstances.length > 1 ? undefined : this.props.almInstances[0];
-  }
+        setRepositoryPaging(paging);
+        setRepositories((prevRepositories) =>
+          page === 1 ? repositories : [...prevRepositories, ...repositories],
+        );
+      } catch (_) {
+        setRepositoryPaging({ pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 });
+        setRepositories([]);
+      } finally {
+        setIsLoadingRepositories(false);
+      }
+    },
+    [selectedDopSetting],
+  );
+
+  const handleImportRepository = useCallback(
+    (repoKeys: string[]) => {
+      if (selectedDopSetting && selectedOrganization && repoKeys.length > 0) {
+        onProjectSetupDone({
+          almSetting: selectedDopSetting.key,
+          creationMode: CreateProjectModes.GitHub,
+          monorepo: false,
+          projects: repoKeys.map((repositoryKey) => ({ repositoryKey })),
+        });
+      }
+    },
+    [onProjectSetupDone, selectedDopSetting, selectedOrganization],
+  );
 
-  async initialize() {
-    const { location, router } = this.props;
-    const { selectedAlmInstance } = this.state;
-    if (!selectedAlmInstance || !selectedAlmInstance.url) {
-      this.setState({ error: true });
-      return;
+  const handleLoadMore = useCallback(() => {
+    if (selectedOrganization) {
+      fetchRepositories({
+        organizationKey: selectedOrganization.key,
+        page: repositoryPaging.pageIndex + 1,
+        query: searchQuery,
+      });
     }
-    this.setState({ error: false });
-
-    const code = location.query?.code;
+  }, [fetchRepositories, repositoryPaging.pageIndex, searchQuery, selectedOrganization]);
+
+  const handleSelectOrganization = useCallback(
+    (organizationKey: string) => {
+      setSearchQuery('');
+      setSelectedOrganization(organizations.find(({ key }) => key === organizationKey));
+      fetchRepositories({ organizationKey });
+    },
+    [fetchRepositories, organizations],
+  );
+
+  const handleSelectRepository = useCallback(
+    (repositoryIdentifier: string) => {
+      setSelectedRepository(repositories.find(({ key }) => key === repositoryIdentifier));
+    },
+    [repositories],
+  );
+
+  const authenticateToGithub = useCallback(async () => {
     try {
-      if (!code) {
-        await this.redirectToGithub(selectedAlmInstance);
-      } else {
-        delete location.query.code;
-        router.replace(location);
-        await this.fetchOrganizations(selectedAlmInstance, code);
-      }
-    } catch (e) {
-      if (this.mounted) {
-        this.setState({ error: true });
-      }
+      await redirectToGithub({ isMonorepoSetup, selectedDopSetting });
+    } catch {
+      setIsInError(true);
     }
-  }
+  }, [isMonorepoSetup, selectedDopSetting]);
+
+  const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => {
+    setSelectedDopSetting(setting);
+    setOrganizations([]);
+    setRepositories([]);
+    setSearchQuery('');
+  }, []);
+
+  const onSelectedAlmInstanceChange = useCallback(
+    (instance: AlmSettingsInstance) => {
+      onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key));
+    },
+    [dopSettings, onSelectDopSetting],
+  );
+
+  useEffect(() => {
+    const selectedDopSettingId = location.query?.dopSetting;
+    if (selectedDopSettingId !== undefined) {
+      const selectedDopSetting = dopSettings.find(({ id }) => id === selectedDopSettingId);
+
+      if (selectedDopSetting) {
+        setSelectedDopSetting(selectedDopSetting);
+      }
 
-  async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) {
-    if (!selectedAlmInstance.url) {
       return;
     }
 
-    const { clientId } = await getGithubClientId(selectedAlmInstance.key);
-
-    if (!clientId) {
-      this.setState({ error: true });
+    if (dopSettings.length > 1) {
+      setSelectedDopSetting(undefined);
       return;
     }
-    const queryParams = [
-      { param: 'client_id', value: clientId },
-      {
-        param: 'redirect_uri',
-        value: encodeURIComponent(
-          `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&almInstance=${
-            selectedAlmInstance.key
-          }`,
-        ),
-      },
-    ]
-      .map(({ param, value }) => `${param}=${value}`)
-      .join('&');
-
-    let instanceRootUrl;
-    // Strip the api section from the url, since we're not hitting the api here.
-    if (selectedAlmInstance.url.includes('/api/v3')) {
-      // GitHub Enterprise
-      instanceRootUrl = selectedAlmInstance.url.replace('/api/v3', '');
-    } else {
-      // github.com
-      instanceRootUrl = selectedAlmInstance.url.replace('api.', '');
-    }
-
-    // strip the trailing /
-    instanceRootUrl = instanceRootUrl.replace(/\/$/, '');
-    if (!isWebUri(instanceRootUrl)) {
-      this.setState({ error: true });
-    } else {
-      window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`);
-    }
-  }
 
-  async fetchOrganizations(selectedAlmInstance: AlmSettingsInstance, token: string) {
-    const { organizations } = await getGithubOrganizations(selectedAlmInstance.key, token);
+    setSelectedDopSetting(dopSettings[0]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [hasDopSettings]);
 
-    if (this.mounted) {
-      this.setState({ loadingOrganizations: false, organizations });
-    }
-  }
-
-  async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) {
-    const { organizationKey, page = 1, query } = params;
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      this.setState({ error: true });
+  useEffect(() => {
+    if (selectedDopSetting?.url === undefined) {
+      setIsInError(true);
       return;
     }
+    setIsInError(false);
 
-    this.setState({ loadingRepositories: true });
-
-    try {
-      const data = await getGithubRepositories({
-        almSetting: selectedAlmInstance.key,
-        organization: organizationKey,
-        pageSize: REPOSITORY_PAGE_SIZE,
-        page,
-        query,
+    const code = location.query?.code;
+    if (code === undefined) {
+      authenticateToGithub().catch(() => {
+        setIsInError(true);
       });
-
-      if (this.mounted) {
-        this.setState(({ repositories }) => ({
-          loadingRepositories: false,
-          repositoryPaging: data.paging,
-          repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories],
-        }));
-      }
-    } catch (_) {
-      if (this.mounted) {
-        this.setState({
-          loadingRepositories: false,
-          repositoryPaging: { pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 },
-          repositories: [],
+    } else {
+      delete location.query.code;
+      router.replace(location);
+
+      getGithubOrganizations(selectedDopSetting.key, code)
+        .then(({ organizations }) => {
+          setOrganizations(organizations);
+          setIsLoadingOrganizations(false);
+        })
+        .catch(() => {
+          setIsInError(true);
         });
-      }
-    }
-  }
-
-  triggerSearch = (query: string) => {
-    const { selectedOrganization } = this.state;
-    if (selectedOrganization) {
-      this.fetchRepositories({ organizationKey: selectedOrganization.key, query });
-    }
-  };
-
-  handleSelectOrganization = (key: string) => {
-    this.setState(({ organizations }) => ({
-      searchQuery: '',
-      selectedOrganization: organizations.find((o) => o.key === key),
-    }));
-    this.fetchRepositories({ organizationKey: key });
-  };
-
-  handleSearch = (searchQuery: string) => {
-    this.setState({ searchQuery });
-    this.triggerSearch(searchQuery);
-  };
-
-  handleLoadMore = () => {
-    const { repositoryPaging, searchQuery, selectedOrganization } = this.state;
-
-    if (selectedOrganization) {
-      this.fetchRepositories({
-        organizationKey: selectedOrganization.key,
-        page: repositoryPaging.pageIndex + 1,
-        query: searchQuery,
-      });
-    }
-  };
-
-  handleImportRepository = (repoKeys: string[]) => {
-    const { selectedOrganization, selectedAlmInstance } = this.state;
-
-    if (selectedAlmInstance && selectedOrganization && repoKeys.length > 0) {
-      this.props.onProjectSetupDone({
-        almSetting: selectedAlmInstance.key,
-        creationMode: CreateProjectModes.GitHub,
-        projects: repoKeys.map((repositoryKey) => ({ repositoryKey })),
-      });
     }
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState(
-      { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] },
-      () => {
-        this.initialize().catch(() => {
-          /* noop */
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [selectedDopSetting]);
+
+  useEffect(() => {
+    repositorySearchDebounceId.current = setTimeout(() => {
+      if (selectedOrganization) {
+        fetchRepositories({
+          organizationKey: selectedOrganization.key,
+          query: searchQuery,
         });
-      },
-    );
-  };
+      }
+    }, REPOSITORY_SEARCH_DEBOUNCE_TIME);
 
-  render() {
-    const { canAdmin, loadingBindings, almInstances } = this.props;
-    const {
-      error,
-      loadingOrganizations,
-      loadingRepositories,
-      organizations,
-      repositoryPaging,
-      repositories,
-      searchQuery,
-      selectedOrganization,
-      selectedAlmInstance,
-    } = this.state;
+    return () => {
+      clearTimeout(repositorySearchDebounceId.current);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [searchQuery]);
+
+  return isMonorepoSetup ? (
+    <MonorepoProjectCreate
+      dopSettings={dopSettings}
+      canAdmin={canAdmin}
+      error={isInError}
+      loadingBindings={isLoadingBindings}
+      loadingOrganizations={isLoadingOrganizations}
+      loadingRepositories={isLoadingRepositories}
+      onProjectSetupDone={onProjectSetupDone}
+      onSearchRepositories={setSearchQuery}
+      onSelectDopSetting={onSelectDopSetting}
+      onSelectOrganization={handleSelectOrganization}
+      onSelectRepository={handleSelectRepository}
+      organizationOptions={organizationOptions}
+      repositoryOptions={repositoryOptions}
+      repositorySearchQuery={searchQuery}
+      selectedDopSetting={selectedDopSetting}
+      selectedOrganization={selectedOrganization && transformToOption(selectedOrganization)}
+      selectedRepository={selectedRepository && transformToOption(selectedRepository)}
+    />
+  ) : (
+    <GitHubProjectCreateRenderer
+      almInstances={dopSettings.map(({ key, type, url }) => ({
+        alm: type,
+        key,
+        url,
+      }))}
+      canAdmin={canAdmin}
+      error={isInError}
+      loadingBindings={isLoadingBindings}
+      loadingOrganizations={isLoadingOrganizations}
+      loadingRepositories={isLoadingRepositories}
+      onImportRepository={handleImportRepository}
+      onLoadMore={handleLoadMore}
+      onSearch={setSearchQuery}
+      onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
+      onSelectOrganization={handleSelectOrganization}
+      organizations={organizations}
+      repositories={repositories}
+      repositoryPaging={repositoryPaging}
+      searchQuery={searchQuery}
+      selectedAlmInstance={
+        selectedDopSetting && {
+          alm: selectedDopSetting.type,
+          key: selectedDopSetting.key,
+          url: selectedDopSetting.url,
+        }
+      }
+      selectedOrganization={selectedOrganization}
+    />
+  );
+}
 
-    return (
-      <GitHubProjectCreateRenderer
-        canAdmin={canAdmin}
-        error={error}
-        loadingBindings={loadingBindings}
-        loadingOrganizations={loadingOrganizations}
-        loadingRepositories={loadingRepositories}
-        onImportRepository={this.handleImportRepository}
-        onLoadMore={this.handleLoadMore}
-        onSearch={this.handleSearch}
-        onSelectOrganization={this.handleSelectOrganization}
-        organizations={organizations}
-        repositoryPaging={repositoryPaging}
-        searchQuery={searchQuery}
-        repositories={repositories}
-        selectedOrganization={selectedOrganization}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-      />
-    );
-  }
+function transformToOption({
+  key,
+  name,
+}: GithubOrganization | GithubRepository): LabelValueSelectOption {
+  return { value: key, label: name };
 }
index 9abb7f27e35dbdb9e667816d3cb601597cb1b23d..d1fba96d8c31de57c8a3840815a36cb07922cc23 100644 (file)
@@ -20,6 +20,7 @@
 /* eslint-disable react/no-unused-prop-types */
 
 import styled from '@emotion/styled';
+import { Link, Spinner } from '@sonarsource/echoes-react';
 import {
   ButtonPrimary,
   Checkbox,
@@ -28,23 +29,25 @@ import {
   InputSearch,
   InputSelect,
   LightPrimary,
-  Link,
-  Spinner,
   Title,
   themeBorder,
   themeColor,
 } from 'design-system';
-import React, { useState } from 'react';
+import React, { useContext, useState } from 'react';
 import { FormattedMessage } from 'react-intl';
+import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
 import ListFooter from '../../../../components/controls/ListFooter';
 import { translate } from '../../../../helpers/l10n';
 import { LabelValueSelectOption } from '../../../../helpers/search';
 import { getBaseUrl } from '../../../../helpers/system';
+import { queryToSearch } from '../../../../helpers/urls';
 import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
 import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Feature } from '../../../../types/features';
 import { Paging } from '../../../../types/types';
 import AlmRepoItem from '../components/AlmRepoItem';
 import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import { CreateProjectModes } from '../types';
 
 interface GitHubProjectCreateRendererProps {
   canAdmin: boolean;
@@ -173,6 +176,10 @@ function RepositoryList(props: RepositoryListProps) {
 }
 
 export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
+  const isMonorepoSupported = useContext(AvailableFeaturesContext).includes(
+    Feature.MonoRepositoryPullRequestDecoration,
+  );
+
   const {
     canAdmin,
     error,
@@ -211,7 +218,28 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
       <header className="sw-mb-10">
         <Title className="sw-mb-4">{translate('onboarding.create_project.github.title')}</Title>
         <LightPrimary className="sw-body-sm">
-          {translate('onboarding.create_project.github.subtitle')}
+          {isMonorepoSupported ? (
+            <FormattedMessage
+              id="onboarding.create_project.github.subtitle.with_monorepo"
+              values={{
+                monorepoSetupLink: (
+                  <Link
+                    to={{
+                      pathname: '/projects/create',
+                      search: queryToSearch({
+                        mode: CreateProjectModes.GitHub,
+                        mono: true,
+                      }),
+                    }}
+                  >
+                    <FormattedMessage id="onboarding.create_project.github.subtitle.link" />
+                  </Link>
+                ),
+              }}
+            />
+          ) : (
+            <FormattedMessage id="onboarding.create_project.github.subtitle" />
+          )}
         </LightPrimary>
       </header>
 
@@ -246,7 +274,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
 
       <div className="sw-flex sw-gap-12">
         <LargeColumn>
-          <Spinner loading={loadingOrganizations && !error}>
+          <Spinner isLoading={loadingOrganizations && !error}>
             {!error && (
               <div className="sw-flex sw-flex-col">
                 <DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/utils.ts b/server/sonar-web/src/main/js/apps/create/project/Github/utils.ts
new file mode 100644 (file)
index 0000000..6df0e64
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { isWebUri } from 'valid-url';
+import { getGithubClientId } from '../../../../api/alm-integrations';
+import { getHostUrl } from '../../../../helpers/urls';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { DopSetting } from '../../../../types/dop-translation';
+
+export async function redirectToGithub(params: {
+  isMonorepoSetup: boolean;
+  selectedDopSetting?: DopSetting;
+}) {
+  const { isMonorepoSetup, selectedDopSetting } = params;
+
+  if (selectedDopSetting?.url === undefined) {
+    return;
+  }
+
+  const { clientId } = await getGithubClientId(selectedDopSetting.key);
+
+  if (clientId === undefined) {
+    throw new Error('Received no GitHub client id');
+  }
+  const queryParams = [
+    { param: 'client_id', value: clientId },
+    {
+      param: 'redirect_uri',
+      value: encodeURIComponent(
+        `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&dopSetting=${
+          selectedDopSetting.id
+        }${isMonorepoSetup ? '&mono=true' : ''}`,
+      ),
+    },
+  ]
+    .map(({ param, value }) => `${param}=${value}`)
+    .join('&');
+
+  let instanceRootUrl;
+  // Strip the api section from the url, since we're not hitting the api here.
+  if (selectedDopSetting.url.includes('/api/v3')) {
+    // GitHub Enterprise
+    instanceRootUrl = selectedDopSetting.url.replace('/api/v3', '');
+  } else {
+    // github.com
+    instanceRootUrl = selectedDopSetting.url.replace('api.', '');
+  }
+
+  // strip the trailing /
+  instanceRootUrl = instanceRootUrl.replace(/\/$/, '');
+  if (isWebUri(instanceRootUrl) === undefined) {
+    throw new Error('Invalid GitHub URL');
+  } else {
+    window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`);
+  }
+}
index b5160f7f69bfbc137897b4df8d56bfaa4c45e195..94dc946da033c01cc8524c5b0abd9449e6caa420 100644 (file)
@@ -143,6 +143,7 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat
       this.props.onProjectSetupDone({
         creationMode: CreateProjectModes.GitLab,
         almSetting: selectedAlmInstance.key,
+        monorepo: false,
         projects: [{ gitlabProjectId }],
       });
     }
index 26e66c1b21084003b2b1d583b77702a8e35667e8..63194307211f2ded1bcfac159e1b1f1c88f471f2 100644 (file)
@@ -24,7 +24,7 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { searchAzureRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
@@ -34,7 +34,7 @@ jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
@@ -53,14 +53,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 afterAll(() => {
index 496b37ba2b9ef95d5da66b30927633129515f808..11f52a278aa107a29ce70f25a0be3bda18210a80 100644 (file)
@@ -24,7 +24,7 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { searchForBitbucketServerRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
@@ -34,7 +34,7 @@ jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
@@ -52,14 +52,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 
index 22ea3acb318986bf00e0afcdda73fa4ced134fdd..ff7f37cc8b63d8c49479cca6cea7eb7bfbb7fdd1 100644 (file)
@@ -24,7 +24,7 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
@@ -35,7 +35,7 @@ jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
@@ -56,14 +56,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 
index cf6366a897ba555ca15c8e1dd022e6c98b854f46..ceb37315dcdc4df46dcb47ded666f98a3c4892ad 100644 (file)
@@ -21,7 +21,7 @@ import { screen } from '@testing-library/react';
 
 import * as React from 'react';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { mockAppState } from '../../../../helpers/testMocks';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
@@ -32,7 +32,7 @@ jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const original = window.location;
@@ -43,14 +43,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 afterAll(() => {
@@ -58,14 +58,14 @@ afterAll(() => {
 });
 
 it('should be able to setup if no config and admin', async () => {
-  almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure);
+  dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure);
   renderCreateProject(true);
   expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument();
   expect(screen.getByRole('button', { name: 'setup' })).toBeInTheDocument();
 });
 
 it('should not be able to setup if no config and no admin rights', async () => {
-  almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure);
+  dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure);
   renderCreateProject();
   expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument();
   expect(screen.queryByRole('button', { name: 'setup' })).not.toBeInTheDocument();
index a742d14d7abc01b216f773ff74852111074ad0df..e34500da034b46fec6a1c1b78439670f8b1cfc66 100644 (file)
@@ -24,7 +24,7 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { getGithubRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
@@ -37,7 +37,7 @@ jest.mock('../../../../api/alm-settings');
 const original = window.location;
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
@@ -76,14 +76,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 
@@ -120,7 +120,7 @@ it('should not redirect to github when url is malformated', async () => {
 it('should show import project feature when the authentication is successfull', async () => {
   const user = userEvent.setup();
 
-  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+  renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');
 
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
 
@@ -172,7 +172,7 @@ it('should import several projects', async () => {
     mockGitHubRepository({ name: 'Github repo 3', key: 'key3' }),
   ]);
 
-  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+  renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');
 
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
 
@@ -237,7 +237,7 @@ it('should import several projects', async () => {
 
 it('should show search filter when the authentication is successful', async () => {
   const user = userEvent.setup();
-  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+  renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');
 
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
 
@@ -247,12 +247,14 @@ it('should show search filter when the authentication is successful', async () =
   await user.click(inputSearch);
   await user.keyboard('search');
 
-  expect(getGithubRepositories).toHaveBeenLastCalledWith({
-    almSetting: 'conf-github-2',
-    organization: 'org-1',
-    page: 1,
-    pageSize: 50,
-    query: 'search',
+  await waitFor(() => {
+    expect(getGithubRepositories).toHaveBeenLastCalledWith({
+      almSetting: 'conf-github-2',
+      organization: 'org-1',
+      page: 1,
+      pageSize: 50,
+      query: 'search',
+    });
   });
 });
 
@@ -260,7 +262,7 @@ it('should have load more', async () => {
   const user = userEvent.setup();
   almIntegrationHandler.createRandomGithubRepositoriessWithLoadMore(10, 20);
 
-  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+  renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');
 
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
 
@@ -288,7 +290,7 @@ it('should have load more', async () => {
 it('should show no result message when there are no projects', async () => {
   almIntegrationHandler.setGithubRepositories([]);
 
-  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+  renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');
 
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
 
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx
new file mode 100644 (file)
index 0000000..84cd593
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import selectEvent from 'react-select-event';
+import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
+import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
+import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
+import { renderApp } from '../../../../helpers/testReactTestingUtils';
+import { byRole } from '../../../../helpers/testSelector';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { Feature } from '../../../../types/features';
+import CreateProjectPage from '../CreateProjectPage';
+import { CreateProjectModes } from '../types';
+
+jest.mock('../../../../api/alm-integrations');
+jest.mock('../../../../api/alm-settings');
+
+let almIntegrationHandler: AlmIntegrationsServiceMock;
+let almSettingsHandler: AlmSettingsServiceMock;
+let componentsHandler: ComponentsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
+let newCodePeriodHandler: NewCodeDefinitionServiceMock;
+
+const ui = {
+  addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }),
+  cancelButton: byRole('button', { name: 'cancel' }),
+  dopSettingSelector: byRole('combobox', {
+    name: `onboarding.create_project.monorepo.choose_dop_setting.${AlmKeys.GitHub}`,
+  }),
+  gitHubOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.github.title' }),
+  monorepoProjectTitle: byRole('heading', {
+    name: 'onboarding.create_project.monorepo.project_title',
+  }),
+  monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }),
+  monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }),
+  organizationSelector: byRole('combobox', {
+    name: `onboarding.create_project.monorepo.choose_organization.${AlmKeys.GitHub}`,
+  }),
+  removeButton: byRole('button', { name: 'onboarding.create_project.monorepo.remove_project' }),
+  repositorySelector: byRole('combobox', {
+    name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`,
+  }),
+  submitButton: byRole('button', { name: 'next' }),
+};
+
+beforeAll(() => {
+  Object.defineProperty(window, 'location', {
+    configurable: true,
+    value: { replace: jest.fn() },
+  });
+  almIntegrationHandler = new AlmIntegrationsServiceMock();
+  almSettingsHandler = new AlmSettingsServiceMock();
+  componentsHandler = new ComponentsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
+  newCodePeriodHandler = new NewCodeDefinitionServiceMock();
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  almIntegrationHandler.reset();
+  almSettingsHandler.reset();
+  componentsHandler.reset();
+  dopTranslationHandler.reset();
+  newCodePeriodHandler.reset();
+});
+
+describe('github monorepo project setup', () => {
+  it('should be able to access monorepo setup page from GitHub project import page', async () => {
+    const user = userEvent.setup();
+    renderCreateProject({ isMonorepo: false });
+
+    await ui.monorepoSetupLink.find();
+
+    await user.click(await ui.monorepoSetupLink.find());
+
+    expect(ui.monorepoTitle.get()).toBeInTheDocument();
+  });
+
+  it('should be able to go back to GitHub onboarding page from monorepo setup page', async () => {
+    const user = userEvent.setup();
+    renderCreateProject();
+
+    await user.click(await ui.cancelButton.find());
+
+    expect(ui.gitHubOnboardingTitle.get()).toBeInTheDocument();
+  });
+
+  it('should be able to set a monorepo project', async () => {
+    const user = userEvent.setup();
+    renderCreateProject({ code: '123', dopSetting: 'dop-setting-test-id', isMonorepo: true });
+
+    expect(await ui.monorepoTitle.find()).toBeInTheDocument();
+
+    expect(await ui.dopSettingSelector.find()).toBeInTheDocument();
+    expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument();
+
+    await waitFor(async () => {
+      await selectEvent.select(await ui.organizationSelector.find(), 'org-1');
+    });
+    expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument();
+
+    await selectEvent.select(await ui.repositorySelector.find(), 'Github repo 1');
+    expect(await ui.monorepoProjectTitle.find()).toBeInTheDocument();
+    let projects = byRole('textbox', {
+      name: /onboarding.create_project.project_key/,
+    }).getAll();
+    expect(projects).toHaveLength(1);
+    expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference');
+    expect(ui.submitButton.get()).toBeEnabled();
+
+    await user.click(ui.addButton.get());
+    await waitFor(() => {
+      projects = byRole('textbox', {
+        name: /onboarding.create_project.project_key/,
+      }).getAll();
+      expect(projects).toHaveLength(2);
+    });
+    expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference');
+    expect(projects[1]).toHaveValue('org-1_Github-repo-1_add-your-reference-1');
+    expect(ui.submitButton.get()).toBeEnabled();
+
+    await user.type(projects[0], '-1');
+    expect(ui.submitButton.get()).toBeDisabled();
+    await user.clear(projects[1]);
+    expect(ui.submitButton.get()).toBeDisabled();
+
+    await user.click(ui.removeButton.getAll()[0]);
+    await waitFor(() => {
+      projects = byRole('textbox', {
+        name: /onboarding.create_project.project_key/,
+      }).getAll();
+      expect(projects).toHaveLength(1);
+    });
+    expect(projects[0]).toHaveValue('');
+    expect(ui.submitButton.get()).toBeDisabled();
+
+    await user.type(projects[0], 'project-key');
+    expect(ui.submitButton.get()).toBeEnabled();
+  });
+});
+
+function renderCreateProject({
+  code,
+  dopSetting,
+  isMonorepo = true,
+}: {
+  code?: string;
+  dopSetting?: string;
+  isMonorepo?: boolean;
+} = {}) {
+  let queryString = `mode=${CreateProjectModes.GitHub}`;
+  if (isMonorepo) {
+    queryString += '&mono=true';
+  }
+  if (dopSetting !== undefined) {
+    queryString += `&dopSetting=${dopSetting}`;
+  }
+  if (code !== undefined) {
+    queryString += `&code=${code}`;
+  }
+
+  renderApp('projects/create', <CreateProjectPage />, {
+    navigateTo: `projects/create?${queryString}`,
+    featureList: [Feature.MonoRepositoryPullRequestDecoration],
+  });
+}
index a0133945c74389b39db1244c97747e67333d2f75..5853b155855072477848d9d419eb502d47e34591 100644 (file)
@@ -23,7 +23,7 @@ import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { getGitlabProjects } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
-import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
@@ -33,7 +33,7 @@ jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
-let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
@@ -53,14 +53,14 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
-  almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
-  almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
 });
 
index bb6f76a3e91828b28e3750b1d312fbdfe467fce7..ebdbc3c1c7d352f85427495c9a8be6585a71b1ee 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { ProjectsServiceMock } from '../../../../api/mocks/ProjectsServiceMock';
 import { getNewCodeDefinition } from '../../../../api/newCodeDefinition';
@@ -34,6 +36,7 @@ import routes from '../../../projects/routes';
 jest.mock('../../../../api/measures');
 jest.mock('../../../../api/favorites');
 jest.mock('../../../../api/alm-settings');
+jest.mock('../../../../api/dop-translation');
 jest.mock('../../../../api/newCodeDefinition');
 jest.mock('../../../../api/project-management', () => ({
   createProject: jest.fn().mockReturnValue(Promise.resolve({ project: mockProject() })),
@@ -98,6 +101,7 @@ async function fillFormAndNext(displayName: string, user: UserEvent) {
 }
 
 let almSettingsHandler: AlmSettingsServiceMock;
+let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 let projectHandler: ProjectsServiceMock;
 
@@ -109,6 +113,7 @@ beforeAll(() => {
     value: { replace: jest.fn() },
   });
   almSettingsHandler = new AlmSettingsServiceMock();
+  dopTranslationHandler = new DopTranslationServiceMock();
   newCodePeriodHandler = new NewCodeDefinitionServiceMock();
   projectHandler = new ProjectsServiceMock();
 });
@@ -116,6 +121,7 @@ beforeAll(() => {
 beforeEach(() => {
   jest.clearAllMocks();
   almSettingsHandler.reset();
+  dopTranslationHandler.reset();
   newCodePeriodHandler.reset();
   projectHandler.reset();
 });
@@ -192,7 +198,7 @@ it('the project onboarding page should be displayed when the project is created'
   expect(await ui.projectDashboardText.find()).toBeInTheDocument();
 });
 
-it('validate the provate key field', async () => {
+it('validate the private key field', async () => {
   const user = userEvent.setup();
   renderCreateProject();
   expect(ui.manualProjectHeader.get()).toBeInTheDocument();
@@ -200,7 +206,9 @@ it('validate the provate key field', async () => {
   await user.click(ui.displayNameField.get());
   await user.keyboard('exists');
 
-  expect(ui.projectNextButton.get()).toBeDisabled();
+  await waitFor(() => {
+    expect(ui.projectNextButton.get()).toBeDisabled();
+  });
   await user.click(ui.projectNextButton.get());
 });
 
index d4d82d2b94e705b17be98e69d4c046adcc3afcc9..92193fd3ec1e1705a391a7f78828c0fcbea678b0 100644 (file)
@@ -23,7 +23,7 @@ import AlmSettingsInstanceSelector from '../../../../components/devops-platform/
 import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n';
 import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
 
-export interface AlmSettingsInstanceDropdownProps {
+interface Props {
   almKey: AlmKeys;
   almInstances?: AlmSettingsInstance[];
   selectedAlmInstance?: AlmSettingsInstance;
@@ -32,7 +32,7 @@ export interface AlmSettingsInstanceDropdownProps {
 
 const MIN_SIZE_INSTANCES = 2;
 
-export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) {
+export default function AlmSettingsInstanceDropdown(props: Readonly<Props>) {
   const { almKey, almInstances, selectedAlmInstance } = props;
   if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) {
     return null;
@@ -43,7 +43,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr
     : `alm.${almKey}`;
 
   return (
-    <div className="sw-flex sw-flex-col">
+    <div className="sw-flex sw-flex-col sw-mb-9">
       <DarkLabel htmlFor="alm-config-selector" className="sw-mb-2">
         {translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
       </DarkLabel>
@@ -51,7 +51,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr
         instances={almInstances}
         onChange={props.onChangeConfig}
         initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
-        className="sw-w-abs-400 sw-mb-9"
+        className="sw-w-abs-400"
         inputId="alm-config-selector"
       />
     </div>
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx b/server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx
new file mode 100644 (file)
index 0000000..f0400c5
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 classNames from 'classnames';
+import { DarkLabel, InputSelect, LabelValueSelectOption, Note } from 'design-system';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { OptionProps, SingleValueProps, components } from 'react-select';
+import { translate } from '../../../../helpers/l10n';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { DopSetting } from '../../../../types/dop-translation';
+
+export interface DopSettingDropdownProps {
+  almKey: AlmKeys;
+  className?: string;
+  dopSettings?: DopSetting[];
+  onChangeSetting: (setting: DopSetting) => void;
+  selectedDopSetting?: DopSetting;
+}
+
+const MIN_SIZE_INSTANCES = 2;
+
+function optionRenderer(props: OptionProps<LabelValueSelectOption<DopSetting>, false>) {
+  return <components.Option {...props}>{customOptions(props.data.value)}</components.Option>;
+}
+
+function singleValueRenderer(props: SingleValueProps<LabelValueSelectOption<DopSetting>, false>) {
+  return (
+    <components.SingleValue {...props}>{customOptions(props.data.value)}</components.SingleValue>
+  );
+}
+
+function customOptions(setting: DopSetting) {
+  return setting.url ? (
+    <>
+      <span>{setting.key} â€” </span>
+      <Note>{setting.url}</Note>
+    </>
+  ) : (
+    <span>{setting.key}</span>
+  );
+}
+
+function orgToOption(alm: DopSetting) {
+  return { value: alm, label: alm.key };
+}
+
+export default function DopSettingDropdown(props: Readonly<DopSettingDropdownProps>) {
+  const { almKey, className, dopSettings, onChangeSetting, selectedDopSetting } = props;
+  if (!dopSettings || dopSettings.length < MIN_SIZE_INSTANCES) {
+    return null;
+  }
+
+  return (
+    <div className={classNames('sw-flex sw-flex-col', className)}>
+      <DarkLabel htmlFor="dop-setting-dropdown" className="sw-mb-2">
+        <FormattedMessage id={`onboarding.create_project.monorepo.choose_dop_setting.${almKey}`} />
+      </DarkLabel>
+
+      <InputSelect
+        inputId="dop-setting-dropdown"
+        className={className}
+        isClearable={false}
+        isSearchable={false}
+        options={dopSettings.map(orgToOption)}
+        onChange={(data: LabelValueSelectOption<DopSetting>) => {
+          onChangeSetting(data.value);
+        }}
+        components={{
+          Option: optionRenderer,
+          SingleValue: singleValueRenderer,
+        }}
+        placeholder={translate('alm.configuration.selector.placeholder')}
+        getOptionValue={(opt: LabelValueSelectOption<DopSetting>) => opt.value.key}
+        value={
+          dopSettings.map(orgToOption).find((opt) => opt.value.key === selectedDopSetting?.key) ??
+          null
+        }
+        size="full"
+      />
+    </div>
+  );
+}
index e35f0e6907fcd0e5105df239d41901acc1cf12df..9f90b8bc8a4ca11455c1e7b6c5c94280862cbf59 100644 (file)
@@ -34,6 +34,7 @@ import * as React from 'react';
 import { useEffect } from 'react';
 import { FormattedMessage, useIntl } from 'react-intl';
 import { useNavigate, unstable_usePrompt as usePrompt } from 'react-router-dom';
+import { useLocation } from '../../../../components/hoc/withRouter';
 import NewCodeDefinitionSelector from '../../../../components/new-code-definition/NewCodeDefinitionSelector';
 import { useDocUrl } from '../../../../helpers/docs';
 import { translate } from '../../../../helpers/l10n';
@@ -65,6 +66,7 @@ export default function NewCodeDefinitionSelection(props: Props) {
   const mutateCount = useImportProjectProgress();
   const isImporting = mutateCount > 0;
   const intl = useIntl();
+  const location = useLocation();
   const navigate = useNavigate();
   const getDocUrl = useDocUrl();
   usePrompt({
@@ -74,10 +76,11 @@ export default function NewCodeDefinitionSelection(props: Props) {
 
   const projectCount = importProjects.projects.length;
   const isMultipleProjects = projectCount > 1;
+  const isMonorepo = location.query?.mono === 'true';
 
   useEffect(() => {
     const redirect = (projectCount: number) => {
-      if (projectCount === 1 && data) {
+      if (!isMonorepo && projectCount === 1 && data) {
         if (redirectTo === '/projects') {
           navigate(getProjectUrl(data.project.key));
         } else {
@@ -110,7 +113,11 @@ export default function NewCodeDefinitionSelection(props: Props) {
       if (redirectTo === '/projects') {
         addGlobalSuccessMessage(
           intl.formatMessage(
-            { id: 'onboarding.create_project.success' },
+            {
+              id: isMonorepo
+                ? 'onboarding.create_project.monorepo.success'
+                : 'onboarding.create_project.success',
+            },
             {
               count: projectCount - failedImports,
             },
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx b/server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx
new file mode 100644 (file)
index 0000000..f07613f
--- /dev/null
@@ -0,0 +1,336 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 classNames from 'classnames';
+import {
+  ButtonSecondary,
+  Card,
+  FlagErrorIcon,
+  FlagSuccessIcon,
+  FormField,
+  InputField,
+  Note,
+  TextError,
+  TrashIcon,
+} from 'design-system';
+import { isEmpty } from 'lodash';
+import * as React from 'react';
+import { doesComponentExists } from '../../../../api/components';
+import { translate } from '../../../../helpers/l10n';
+import { validateProjectKey } from '../../../../helpers/projects';
+import { ProjectKeyValidationResult } from '../../../../types/component';
+import { PROJECT_NAME_MAX_LEN } from '../constants';
+import { getSanitizedProjectKey } from '../utils';
+
+interface Props<I> {
+  initialKey?: string;
+  initialName?: string;
+  monorepoSetupProjectKeys?: string[];
+  onChange: (project: ProjectData<I>) => void;
+  onRemove?: () => void;
+  projectId?: I;
+}
+
+interface State {
+  name: string;
+  nameError?: boolean;
+  nameTouched: boolean;
+  key: string;
+  keyError?: ProjectKeyErrors;
+  keyTouched: boolean;
+  validatingKey: boolean;
+}
+
+export interface ProjectData<I = string> {
+  hasError: boolean;
+  id?: I;
+  name: string;
+  key: string;
+  touched: boolean;
+}
+
+enum ProjectKeyErrors {
+  DuplicateKey = 'DUPLICATE_KEY',
+  MonorepoDuplicateKey = 'MONOREPO_DUPLICATE_KEY',
+  WrongFormat = 'WRONG_FORMAT',
+}
+
+const DEBOUNCE_DELAY = 250;
+
+export default function ProjectValidation<I>(props: Readonly<Props<I>>) {
+  const {
+    initialKey = '',
+    initialName = '',
+    monorepoSetupProjectKeys,
+    onChange,
+    projectId,
+  } = props;
+  const checkFreeKeyTimeout = React.useRef<NodeJS.Timeout | undefined>();
+  const [project, setProject] = React.useState<State>({
+    key: initialKey,
+    name: initialName,
+    keyTouched: false,
+    nameTouched: false,
+    validatingKey: false,
+  });
+
+  const { key, keyError, keyTouched, name, nameError, nameTouched, validatingKey } = project;
+
+  React.useEffect(() => {
+    onChange({
+      hasError: keyError !== undefined || nameError !== undefined,
+      id: projectId,
+      key,
+      name,
+      touched: keyTouched || nameTouched,
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [key, name, keyError, keyTouched, nameError, nameTouched]);
+
+  const checkFreeKey = (keyVal: string) => {
+    setProject((prevProject) => ({ ...prevProject, validatingKey: true }));
+
+    doesComponentExists({ component: keyVal })
+      .then((alreadyExist) => {
+        setProject((prevProject) => {
+          if (keyVal === prevProject.key) {
+            return {
+              ...prevProject,
+              keyError: alreadyExist ? ProjectKeyErrors.DuplicateKey : undefined,
+              validatingKey: false,
+            };
+          }
+          return prevProject;
+        });
+      })
+      .catch(() => {
+        setProject((prevProject) => {
+          if (keyVal === prevProject.key) {
+            return {
+              ...prevProject,
+              keyError: undefined,
+              validatingKey: false,
+            };
+          }
+          return prevProject;
+        });
+      });
+  };
+
+  const handleProjectKeyChange = (projectKey: string, fromUI = false) => {
+    const keyError = validateKey(projectKey);
+
+    setProject((prevProject) => ({
+      ...prevProject,
+      key: projectKey,
+      keyError,
+      keyTouched: fromUI,
+    }));
+  };
+
+  React.useEffect(() => {
+    if (nameTouched && !keyTouched) {
+      const sanitizedProjectKey = getSanitizedProjectKey(name);
+
+      handleProjectKeyChange(sanitizedProjectKey);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [name, keyTouched]);
+
+  React.useEffect(() => {
+    if (!keyError && key !== '') {
+      checkFreeKeyTimeout.current = setTimeout(() => {
+        checkFreeKey(key);
+        checkFreeKeyTimeout.current = undefined;
+      }, DEBOUNCE_DELAY);
+    }
+
+    return () => {
+      if (checkFreeKeyTimeout.current !== undefined) {
+        clearTimeout(checkFreeKeyTimeout.current);
+      }
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [key]);
+
+  React.useEffect(() => {
+    if (
+      (keyError === undefined || keyError === ProjectKeyErrors.MonorepoDuplicateKey) &&
+      key !== ''
+    ) {
+      if (monorepoSetupProjectKeys?.indexOf(key) !== monorepoSetupProjectKeys?.lastIndexOf(key)) {
+        setProject((prevProject) => ({
+          ...prevProject,
+          keyError: ProjectKeyErrors.MonorepoDuplicateKey,
+        }));
+      } else {
+        setProject((prevProject) => {
+          if (prevProject.keyError === ProjectKeyErrors.MonorepoDuplicateKey) {
+            return {
+              ...prevProject,
+              keyError: undefined,
+            };
+          }
+
+          return prevProject;
+        });
+      }
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [monorepoSetupProjectKeys]);
+
+  const handleProjectNameChange = (projectName: string, fromUI = false) => {
+    setProject({
+      ...project,
+      name: projectName,
+      nameError: validateName(projectName),
+      nameTouched: fromUI,
+    });
+  };
+
+  const validateKey = (projectKey: string) => {
+    const result = validateProjectKey(projectKey);
+    if (result !== ProjectKeyValidationResult.Valid) {
+      return ProjectKeyErrors.WrongFormat;
+    }
+    return undefined;
+  };
+
+  const validateName = (projectName: string) => {
+    if (isEmpty(projectName)) {
+      return true;
+    }
+    return undefined;
+  };
+
+  const touched = Boolean(keyTouched || nameTouched);
+  const projectNameIsInvalid = nameTouched && nameError !== undefined;
+  const projectNameIsValid = nameTouched && nameError === undefined;
+  const projectKeyIsInvalid = touched && keyError !== undefined;
+  const projectKeyIsValid = touched && !validatingKey && keyError === undefined;
+  const projectKeyInputId = projectId !== undefined ? `project-key-${projectId}` : 'project-key';
+  const projectNameInputId = projectId !== undefined ? `project-name-${projectId}` : 'project-name';
+
+  return (
+    <>
+      <FormField
+        htmlFor={projectNameInputId}
+        label={translate('onboarding.create_project.display_name')}
+        required
+      >
+        <div>
+          <InputField
+            className={classNames({
+              'js__is-invalid': projectNameIsInvalid,
+            })}
+            size="large"
+            id={projectNameInputId}
+            maxLength={PROJECT_NAME_MAX_LEN}
+            minLength={1}
+            onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
+            type="text"
+            value={name}
+            autoFocus
+            isInvalid={projectNameIsInvalid}
+            isValid={projectNameIsValid}
+            required
+          />
+          {projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
+          {projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
+        </div>
+        {nameError !== undefined && (
+          <Note className="sw-mt-2">
+            {translate('onboarding.create_project.display_name.description')}
+          </Note>
+        )}
+      </FormField>
+
+      <FormField
+        htmlFor={projectKeyInputId}
+        label={translate('onboarding.create_project.project_key')}
+        required
+      >
+        <div>
+          <InputField
+            className={classNames({
+              'js__is-invalid': projectKeyIsInvalid,
+            })}
+            size="large"
+            id={projectKeyInputId}
+            minLength={1}
+            onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
+            type="text"
+            value={key}
+            isInvalid={projectKeyIsInvalid}
+            isValid={projectKeyIsValid}
+            required
+          />
+          {projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
+          {projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
+        </div>
+        {keyError !== undefined && (
+          <Note className="sw-flex-col sw-mt-2">
+            {keyError === ProjectKeyErrors.DuplicateKey ||
+              (keyError === ProjectKeyErrors.MonorepoDuplicateKey && (
+                <TextError
+                  text={translate('onboarding.create_project.project_key.duplicate_key')}
+                />
+              ))}
+            {!isEmpty(key) && keyError === ProjectKeyErrors.WrongFormat && (
+              <TextError text={translate('onboarding.create_project.project_key.wrong_format')} />
+            )}
+            <p>{translate('onboarding.create_project.project_key.description')}</p>
+          </Note>
+        )}
+      </FormField>
+    </>
+  );
+}
+
+export function ProjectValidationCard<I>({
+  initialKey,
+  initialName,
+  monorepoSetupProjectKeys,
+  onChange,
+  onRemove,
+  projectId,
+  ...cardProps
+}: Readonly<
+  Props<I> & Omit<React.ComponentPropsWithoutRef<typeof Card>, 'onChange' | 'children'>
+>) {
+  return (
+    <Card {...cardProps}>
+      <ProjectValidation
+        initialKey={initialKey}
+        initialName={initialName}
+        monorepoSetupProjectKeys={monorepoSetupProjectKeys}
+        onChange={onChange}
+        projectId={projectId}
+      />
+      <ButtonSecondary
+        className="sw-mt-4 sw-mr-4"
+        icon={<TrashIcon />}
+        onClick={onRemove}
+        type="button"
+      >
+        {translate('onboarding.create_project.monorepo.remove_project')}
+      </ButtonSecondary>
+    </Card>
+  );
+}
index bfa875ca219582d66b1482b22f6016a2c2523ea7..b6ff4ea675ab7830685cc57bad15f05d657b3e18 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 export const PROJECT_NAME_MAX_LEN = 255;
 
 export const DEFAULT_BBS_PAGE_SIZE = 25;
index 0df7e73c4694b410f4a0b6e0dee337500d96adb5..6804fc11ddf4341897185f72752cb0813215dcff 100644 (file)
@@ -30,21 +30,17 @@ import {
   InteractiveIcon,
   Link,
   Note,
-  TextError,
   Title,
 } from 'design-system';
-import { debounce, isEmpty } from 'lodash';
+import { isEmpty } from 'lodash';
 import * as React from 'react';
 import { FormattedMessage, useIntl } from 'react-intl';
-import { doesComponentExists } from '../../../../api/components';
 import { getValue } from '../../../../api/settings';
 import { useDocUrl } from '../../../../helpers/docs';
 import { translate } from '../../../../helpers/l10n';
-import { PROJECT_KEY_INVALID_CHARACTERS, validateProjectKey } from '../../../../helpers/projects';
-import { ProjectKeyValidationResult } from '../../../../types/component';
 import { GlobalSettingKeys } from '../../../../types/settings';
 import { ImportProjectParam } from '../CreateProjectPage';
-import { PROJECT_NAME_MAX_LEN } from '../constants';
+import ProjectValidation, { ProjectData } from '../components/ProjectValidation';
 import { CreateProjectModes } from '../types';
 
 interface Props {
@@ -53,94 +49,36 @@ interface Props {
   onClose: () => void;
 }
 
-interface State {
-  projectName: string;
-  projectNameError?: boolean;
-  projectNameTouched: boolean;
-  projectKey: string;
-  projectKeyError?: 'DUPLICATE_KEY' | 'WRONG_FORMAT';
-  projectKeyTouched: boolean;
-  validatingProjectKey: boolean;
+interface MainBranchState {
   mainBranchName: string;
   mainBranchNameError?: boolean;
   mainBranchNameTouched: boolean;
 }
 
-const DEBOUNCE_DELAY = 250;
-
-type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;
+type ValidState = ProjectData & Required<Pick<ProjectData, 'key' | 'name'>>;
 
 export default function ManualProjectCreate(props: Readonly<Props>) {
-  const [project, setProject] = React.useState<State>({
-    projectKey: '',
-    projectName: '',
-    projectKeyTouched: false,
-    projectNameTouched: false,
+  const [mainBranch, setMainBranch] = React.useState<MainBranchState>({
     mainBranchName: 'main',
     mainBranchNameTouched: false,
-    validatingProjectKey: false,
   });
+  const [project, setProject] = React.useState<ProjectData>({
+    hasError: false,
+    key: '',
+    name: '',
+    touched: false,
+  });
+
   const intl = useIntl();
   const docUrl = useDocUrl();
 
-  const checkFreeKey = React.useCallback(
-    debounce((key: string) => {
-      setProject((prevProject) => ({ ...prevProject, validatingProjectKey: true }));
-
-      doesComponentExists({ component: key })
-        .then((alreadyExist) => {
-          setProject((prevProject) => {
-            if (key === prevProject.projectKey) {
-              return {
-                ...prevProject,
-                projectKeyError: alreadyExist ? 'DUPLICATE_KEY' : undefined,
-                validatingProjectKey: false,
-              };
-            }
-            return prevProject;
-          });
-        })
-        .catch(() => {
-          setProject((prevProject) => {
-            if (key === prevProject.projectKey) {
-              return {
-                ...prevProject,
-                projectKeyError: undefined,
-                validatingProjectKey: false,
-              };
-            }
-            return prevProject;
-          });
-        });
-    }, DEBOUNCE_DELAY),
-    [],
-  );
-
-  const handleProjectKeyChange = React.useCallback(
-    (projectKey: string, fromUI = false) => {
-      const projectKeyError = validateKey(projectKey);
-
-      setProject((prevProject) => ({
-        ...prevProject,
-        projectKey,
-        projectKeyError,
-        projectKeyTouched: fromUI,
-      }));
-
-      if (projectKeyError === undefined) {
-        checkFreeKey(projectKey);
-      }
-    },
-    [checkFreeKey],
-  );
-
   React.useEffect(() => {
     async function fetchMainBranchName() {
       const { value: mainBranchName } = await getValue({ key: GlobalSettingKeys.MainBranchName });
 
       if (mainBranchName !== undefined) {
-        setProject((prevProject) => ({
-          ...prevProject,
+        setMainBranch((prevBranchName) => ({
+          ...prevBranchName,
           mainBranchName,
         }));
       }
@@ -149,37 +87,25 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
     fetchMainBranchName();
   }, []);
 
-  React.useEffect(() => {
-    if (!project.projectKeyTouched) {
-      const sanitizedProjectKey = project.projectName
-        .trim()
-        .replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
-
-      handleProjectKeyChange(sanitizedProjectKey);
-    }
-  }, [project.projectName, project.projectKeyTouched, handleProjectKeyChange]);
-
-  const canSubmit = (state: State): state is ValidState => {
-    const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state;
-    return Boolean(
-      projectKeyError === undefined &&
-        projectNameError === undefined &&
-        !isEmpty(projectKey) &&
-        !isEmpty(projectName) &&
-        !isEmpty(mainBranchName),
-    );
+  const canSubmit = (
+    mainBranch: MainBranchState,
+    projectData: ProjectData,
+  ): projectData is ValidState => {
+    const { mainBranchName } = mainBranch;
+    const { key, name, hasError } = projectData;
+    return Boolean(!hasError && !isEmpty(key) && !isEmpty(name) && !isEmpty(mainBranchName));
   };
 
   const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
-    const { projectKey, projectName, mainBranchName } = project;
-    if (canSubmit(project)) {
+    if (canSubmit(mainBranch, project)) {
       props.onProjectSetupDone({
         creationMode: CreateProjectModes.Manual,
+        monorepo: false,
         projects: [
           {
-            project: projectKey,
-            name: (projectName || projectKey).trim(),
+            project: project.key,
+            name: (project.name ?? project.key).trim(),
             mainBranch: mainBranchName,
           },
         ],
@@ -187,39 +113,14 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
     }
   };
 
-  const handleProjectNameChange = (projectName: string, fromUI = false) => {
-    setProject({
-      ...project,
-      projectName,
-      projectNameError: validateName(projectName),
-      projectNameTouched: fromUI,
-    });
-  };
-
   const handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
-    setProject({
-      ...project,
+    setMainBranch({
       mainBranchName,
       mainBranchNameError: validateMainBranchName(mainBranchName),
       mainBranchNameTouched: fromUI,
     });
   };
 
-  const validateKey = (projectKey: string) => {
-    const result = validateProjectKey(projectKey);
-    if (result !== ProjectKeyValidationResult.Valid) {
-      return 'WRONG_FORMAT';
-    }
-    return undefined;
-  };
-
-  const validateName = (projectName: string) => {
-    if (isEmpty(projectName)) {
-      return true;
-    }
-    return undefined;
-  };
-
   const validateMainBranchName = (mainBranchName: string) => {
     if (isEmpty(mainBranchName)) {
       return true;
@@ -227,25 +128,9 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
     return undefined;
   };
 
-  const {
-    projectKey,
-    projectKeyError,
-    projectKeyTouched,
-    projectName,
-    projectNameError,
-    projectNameTouched,
-    validatingProjectKey,
-    mainBranchName,
-    mainBranchNameError,
-    mainBranchNameTouched,
-  } = project;
+  const { mainBranchName, mainBranchNameError, mainBranchNameTouched } = mainBranch;
   const { branchesEnabled } = props;
 
-  const touched = Boolean(projectKeyTouched || projectNameTouched);
-  const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
-  const projectNameIsValid = projectNameTouched && projectNameError === undefined;
-  const projectKeyIsInvalid = touched && projectKeyError !== undefined;
-  const projectKeyIsValid = touched && !validatingProjectKey && projectKeyError === undefined;
   const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
   const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;
 
@@ -279,71 +164,7 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
           className="sw-flex-col sw-body-sm"
           onSubmit={handleFormSubmit}
         >
-          <FormField
-            htmlFor="project-name"
-            label={translate('onboarding.create_project.display_name')}
-            required
-          >
-            <div>
-              <InputField
-                className={classNames({
-                  'js__is-invalid': projectNameIsInvalid,
-                })}
-                size="large"
-                id="project-name"
-                maxLength={PROJECT_NAME_MAX_LEN}
-                minLength={1}
-                onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
-                type="text"
-                value={projectName}
-                autoFocus
-                isInvalid={projectNameIsInvalid}
-                isValid={projectNameIsValid}
-                required
-              />
-              {projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
-              {projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
-            </div>
-            <Note className="sw-mt-2">
-              {translate('onboarding.create_project.display_name.description')}
-            </Note>
-          </FormField>
-
-          <FormField
-            htmlFor="project-key"
-            label={translate('onboarding.create_project.project_key')}
-            required
-          >
-            <div>
-              <InputField
-                className={classNames({
-                  'js__is-invalid': projectKeyIsInvalid,
-                })}
-                size="large"
-                id="project-key"
-                minLength={1}
-                onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
-                type="text"
-                value={projectKey}
-                isInvalid={projectKeyIsInvalid}
-                isValid={projectKeyIsValid}
-                required
-              />
-              {projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
-              {projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
-            </div>
-            <Note className="sw-flex-col sw-mt-2">
-              {projectKeyError === 'DUPLICATE_KEY' && (
-                <TextError
-                  text={translate('onboarding.create_project.project_key.duplicate_key')}
-                />
-              )}
-              {!isEmpty(projectKey) && projectKeyError === 'WRONG_FORMAT' && (
-                <TextError text={translate('onboarding.create_project.project_key.wrong_format')} />
-              )}
-              <p>{translate('onboarding.create_project.project_key.description')}</p>
-            </Note>
-          </FormField>
+          <ProjectValidation onChange={setProject} />
 
           <FormField
             htmlFor="main-branch-name"
@@ -386,7 +207,11 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
           <ButtonSecondary className="sw-mt-4 sw-mr-4" onClick={props.onClose} type="button">
             {intl.formatMessage({ id: 'cancel' })}
           </ButtonSecondary>
-          <ButtonPrimary className="sw-mt-4" type="submit" disabled={!canSubmit(project)}>
+          <ButtonPrimary
+            className="sw-mt-4"
+            type="submit"
+            disabled={!canSubmit(mainBranch, project)}
+          >
             {translate('next')}
           </ButtonPrimary>
         </form>
diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
new file mode 100644 (file)
index 0000000..25a7399
--- /dev/null
@@ -0,0 +1,362 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { Link, Spinner } from '@sonarsource/echoes-react';
+import {
+  AddNewIcon,
+  BlueGreySeparator,
+  ButtonPrimary,
+  ButtonSecondary,
+  DarkLabel,
+  FlagMessage,
+  InputSelect,
+  SubTitle,
+  Title,
+} from 'design-system';
+import React, { useEffect, useRef } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
+import { translate } from '../../../../helpers/l10n';
+import { LabelValueSelectOption } from '../../../../helpers/search';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { DopSetting } from '../../../../types/dop-translation';
+import { ImportProjectParam } from '../CreateProjectPage';
+import DopSettingDropdown from '../components/DopSettingDropdown';
+import { ProjectData, ProjectValidationCard } from '../components/ProjectValidation';
+import { CreateProjectModes } from '../types';
+import { getSanitizedProjectKey } from '../utils';
+import { MonorepoProjectHeader } from './MonorepoProjectHeader';
+
+interface MonorepoProjectCreateProps {
+  canAdmin: boolean;
+  dopSettings: DopSetting[];
+  error: boolean;
+  loadingBindings: boolean;
+  loadingOrganizations: boolean;
+  loadingRepositories: boolean;
+  onProjectSetupDone: (importProjects: ImportProjectParam) => void;
+  onSearchRepositories: (query: string) => void;
+  onSelectDopSetting: (instance: DopSetting) => void;
+  onSelectOrganization: (organizationKey: string) => void;
+  onSelectRepository: (repositoryIdentifier: string) => void;
+  organizationOptions?: LabelValueSelectOption[];
+  repositoryOptions?: LabelValueSelectOption[];
+  repositorySearchQuery: string;
+  selectedDopSetting?: DopSetting;
+  selectedOrganization?: LabelValueSelectOption;
+  selectedRepository?: LabelValueSelectOption;
+}
+
+type ProjectItem = Required<ProjectData<number>>;
+
+export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCreateProps>) {
+  const {
+    dopSettings,
+    canAdmin,
+    error,
+    loadingBindings,
+    loadingOrganizations,
+    loadingRepositories,
+    onProjectSetupDone,
+    onSearchRepositories,
+    onSelectDopSetting,
+    onSelectOrganization,
+    onSelectRepository,
+    organizationOptions,
+    repositoryOptions,
+    repositorySearchQuery,
+    selectedDopSetting,
+    selectedOrganization,
+    selectedRepository,
+  } = props;
+
+  const projectCounter = useRef(0);
+
+  const [projects, setProjects] = React.useState<ProjectItem[]>([]);
+
+  const location = useLocation();
+  const { push } = useRouter();
+  const { formatMessage } = useIntl();
+
+  const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]);
+
+  const almKey = location.query.mode as AlmKeys;
+
+  const isSetupInvalid =
+    selectedDopSetting === undefined ||
+    selectedOrganization === undefined ||
+    selectedRepository === undefined ||
+    projects.length === 0 ||
+    projects.some(({ hasError, key, name }) => hasError || key === '' || name === '');
+
+  const addProject = () => {
+    if (selectedOrganization === undefined || selectedRepository === undefined) {
+      return;
+    }
+
+    const id = projectCounter.current;
+    projectCounter.current += 1;
+
+    const projectKeySuffix = id === 0 ? '' : `-${id}`;
+    const projectKey = getSanitizedProjectKey(
+      `${selectedOrganization.label}_${selectedRepository.label}_add-your-reference${projectKeySuffix}`,
+    );
+
+    const newProjects = [
+      ...projects,
+      {
+        hasError: false,
+        id,
+        key: projectKey,
+        name: projectKey,
+        touched: false,
+      },
+    ];
+
+    setProjects(newProjects);
+  };
+
+  const onProjectChange = (project: ProjectItem) => {
+    const newProjects = projects.filter(({ id }) => id !== project.id);
+    newProjects.push({
+      ...project,
+    });
+    newProjects.sort((a, b) => a.id - b.id);
+
+    setProjects(newProjects);
+  };
+
+  const onProjectRemove = (id: number) => {
+    const newProjects = projects.filter(({ id: projectId }) => projectId !== id);
+
+    setProjects(newProjects);
+  };
+
+  const cancelMonorepoSetup = () => {
+    push({
+      pathname: location.pathname,
+      query: { mode: AlmKeys.GitHub },
+    });
+  };
+
+  const submitProjects = () => {
+    if (isSetupInvalid) {
+      return;
+    }
+
+    const monorepoSetup: ImportProjectParam = {
+      creationMode: almKey as unknown as CreateProjectModes,
+      devOpsPlatformSettingId: selectedDopSetting.id,
+      monorepo: true,
+      projects: projects.map(({ key: projectKey, name: projectName }) => ({
+        projectKey,
+        projectName,
+      })),
+      repositoryIdentifier: selectedRepository.value,
+    };
+
+    onProjectSetupDone(monorepoSetup);
+  };
+
+  useEffect(() => {
+    if (selectedRepository !== undefined && projects.length === 0) {
+      addProject();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [selectedRepository]);
+
+  if (loadingBindings) {
+    return <Spinner />;
+  }
+
+  return (
+    <div>
+      <MonorepoProjectHeader />
+
+      <BlueGreySeparator className="sw-my-5" />
+
+      <div className="sw-flex sw-flex-col sw-gap-6">
+        <Title>
+          <FormattedMessage
+            id={`onboarding.create_project.monorepo.choose_organization_and_repository.${almKey}`}
+          />
+        </Title>
+
+        <DopSettingDropdown
+          almKey={almKey}
+          dopSettings={dopSettings}
+          selectedDopSetting={selectedDopSetting}
+          onChangeSetting={onSelectDopSetting}
+        />
+
+        {error && selectedDopSetting && !loadingOrganizations && (
+          <FlagMessage variant="warning">
+            <span>
+              {canAdmin ? (
+                <FormattedMessage
+                  id="onboarding.create_project.github.warning.message_admin"
+                  defaultMessage={translate(
+                    'onboarding.create_project.github.warning.message_admin',
+                  )}
+                  values={{
+                    link: (
+                      <Link to="/admin/settings?category=almintegration">
+                        {translate('onboarding.create_project.github.warning.message_admin.link')}
+                      </Link>
+                    ),
+                  }}
+                />
+              ) : (
+                translate('onboarding.create_project.github.warning.message')
+              )}
+            </span>
+          </FlagMessage>
+        )}
+
+        <div className="sw-flex sw-flex-col">
+          <Spinner isLoading={loadingOrganizations && !error}>
+            {!error && (
+              <>
+                <DarkLabel htmlFor="monorepo-choose-organization" className="sw-mb-2">
+                  <FormattedMessage
+                    id={`onboarding.create_project.monorepo.choose_organization.${almKey}`}
+                  />
+                </DarkLabel>
+                {(organizationOptions?.length ?? 0) > 0 ? (
+                  <InputSelect
+                    size="full"
+                    isSearchable
+                    inputId="monorepo-choose-organization"
+                    options={organizationOptions}
+                    onChange={({ value }: LabelValueSelectOption) => {
+                      onSelectOrganization(value);
+                    }}
+                    placeholder={formatMessage({
+                      id: `onboarding.create_project.monorepo.choose_organization.${almKey}.placeholder`,
+                    })}
+                    value={selectedOrganization}
+                  />
+                ) : (
+                  !loadingOrganizations && (
+                    <FlagMessage variant="error" className="sw-mb-2">
+                      <span>
+                        {canAdmin ? (
+                          <FormattedMessage
+                            id="onboarding.create_project.github.no_orgs_admin"
+                            defaultMessage={translate(
+                              'onboarding.create_project.github.no_orgs_admin',
+                            )}
+                            values={{
+                              link: (
+                                <Link to="/admin/settings?category=almintegration">
+                                  {translate(
+                                    'onboarding.create_project.github.warning.message_admin.link',
+                                  )}
+                                </Link>
+                              ),
+                            }}
+                          />
+                        ) : (
+                          translate('onboarding.create_project.github.no_orgs')
+                        )}
+                      </span>
+                    </FlagMessage>
+                  )
+                )}
+              </>
+            )}
+          </Spinner>
+        </div>
+
+        <div className="sw-flex sw-flex-col">
+          {selectedOrganization && (
+            <DarkLabel className="sw-mb-2" htmlFor="monorepo-choose-repository">
+              <FormattedMessage
+                id={`onboarding.create_project.monorepo.choose_repository.${almKey}`}
+              />
+            </DarkLabel>
+          )}
+          {selectedOrganization && (
+            <InputSelect
+              inputId="monorepo-choose-repository"
+              inputValue={repositorySearchQuery}
+              isLoading={loadingRepositories}
+              isSearchable
+              noOptionsMessage={() => formatMessage({ id: 'no_results' })}
+              onChange={({ value }: LabelValueSelectOption) => {
+                onSelectRepository(value);
+              }}
+              onInputChange={onSearchRepositories}
+              options={repositoryOptions}
+              placeholder={formatMessage({
+                id: `onboarding.create_project.monorepo.choose_repository.${almKey}.placeholder`,
+              })}
+              size="full"
+              value={selectedRepository}
+            />
+          )}
+        </div>
+      </div>
+
+      {selectedRepository !== undefined && (
+        <>
+          <BlueGreySeparator className="sw-my-5" />
+
+          <div>
+            <SubTitle>
+              <FormattedMessage id="onboarding.create_project.monorepo.project_title" />
+            </SubTitle>
+            <div>
+              {projects.map(({ id, key, name }) => (
+                <ProjectValidationCard
+                  className="sw-mt-4"
+                  initialKey={key}
+                  initialName={name}
+                  key={id}
+                  monorepoSetupProjectKeys={projectKeys}
+                  onChange={onProjectChange}
+                  onRemove={() => {
+                    onProjectRemove(id);
+                  }}
+                  projectId={id}
+                />
+              ))}
+            </div>
+
+            <div className="sw-flex sw-justify-end sw-mt-4">
+              <ButtonSecondary onClick={addProject}>
+                <AddNewIcon className="sw-mr-2" />
+                <FormattedMessage id="onboarding.create_project.monorepo.add_project" />
+              </ButtonSecondary>
+            </div>
+          </div>
+        </>
+      )}
+
+      <div className="sw-my-5">
+        <ButtonSecondary onClick={cancelMonorepoSetup}>
+          <FormattedMessage id="cancel" />
+        </ButtonSecondary>
+        <ButtonPrimary className="sw-ml-3" disabled={isSetupInvalid} onClick={submitProjects}>
+          <FormattedMessage id="next" />
+        </ButtonPrimary>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx
new file mode 100644 (file)
index 0000000..d4ba81d
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { LinkStandalone } from '@sonarsource/echoes-react';
+import { LightPrimary, Title } from 'design-system/lib';
+import React from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { useLocation } from '../../../../components/hoc/withRouter';
+import { useDocUrl } from '../../../../helpers/docs';
+
+export function MonorepoProjectHeader() {
+  const { formatMessage } = useIntl();
+  const { query } = useLocation();
+  const almKey = query.mode as string;
+
+  return (
+    <>
+      <Title>
+        <FormattedMessage
+          id="onboarding.create_project.monorepo.title"
+          values={{
+            almName: formatMessage({ id: `alm.${almKey}` }),
+          }}
+        />
+      </Title>
+      <div>
+        <LightPrimary>
+          <FormattedMessage id="onboarding.create_project.monorepo.subtitle" />
+        </LightPrimary>
+      </div>
+      <div className="sw-mt-3">
+        <LinkStandalone isExternal to={useDocUrl('/project-administration/monorepos/')}>
+          <FormattedMessage id="onboarding.create_project.monorepo.doc_link" />
+        </LinkStandalone>
+      </div>
+    </>
+  );
+}
index a4352b54e4a7b40caf301bffdbd112e11b560850..cbda3f656ef29522dfce9f80141f548aa7ecffd0 100644 (file)
@@ -24,5 +24,4 @@ export enum CreateProjectModes {
   BitbucketCloud = 'bitbucketcloud',
   GitHub = 'github',
   GitLab = 'gitlab',
-  Monorepo = 'monorepo',
 }
index 4992a550c0582031fdeb19a91a13fbbcf14bbb45..475c13b6dd4c3d3261fc7f69b2a09407371c1f9c 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { PROJECT_KEY_INVALID_CHARACTERS } from '../../../helpers/projects';
+
 export function tokenExistedBefore(error?: string) {
   return error?.includes('is missing');
 }
+
+export function getSanitizedProjectKey(projectKey: string) {
+  return projectKey.trim().replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
+}
index 1d4a0e3642687ca1ab24919daf21355f41f2b558..0e3fa7f923588f178c763e91a26b4f6d1dfe7269 100644 (file)
@@ -24,7 +24,6 @@ import withAvailableFeatures, {
   WithAvailableFeaturesProps,
 } from '../../../../app/components/available-features/withAvailableFeatures';
 import DocumentationLink from '../../../../components/common/DocumentationLink';
-import { ALM_DOCUMENTATION_PATHS } from '../../../../helpers/constants';
 import { translate } from '../../../../helpers/l10n';
 import { convertGithubApiUrlToLink, stripTrailingSlash } from '../../../../helpers/urls';
 import {
@@ -294,7 +293,7 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) {
           help: true,
           helpParams: {
             doc_link: (
-              <DocumentationLink to={ALM_DOCUMENTATION_PATHS[alm]}>
+              <DocumentationLink to="/project-administration/monorepos/">
                 {translate('learn_more')}
               </DocumentationLink>
             ),
index b743484d85b348fe8628cd9690257c27e283bf93..920de7f5f4aeade2c7b43e700d073ecd4eaf87fe 100644 (file)
@@ -33,14 +33,14 @@ import { Location, withRouter } from '../hoc/withRouter';
 import TutorialSelectionRenderer from './TutorialSelectionRenderer';
 import { TutorialModes } from './types';
 
-interface Props {
+export interface TutorialSelectionProps {
   component: Component;
   currentUser: LoggedInUser;
   willRefreshAutomatically?: boolean;
   location: Location;
 }
 
-export function TutorialSelection(props: Props) {
+export function TutorialSelection(props: Readonly<TutorialSelectionProps>) {
   const { component, currentUser, location, willRefreshAutomatically } = props;
   const [currentUserCanScanProject, setCurrentUserCanScanProject] = React.useState(false);
   const [baseUrl, setBaseUrl] = React.useState(getHostUrl());
index 9a5328edeaabbd8f9b67b779fb43b3e0a9daa86a..842584e7d6123cef4074a44d1d02d3fd4a290837 100644 (file)
@@ -258,6 +258,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
           baseUrl={baseUrl}
           component={component}
           currentUser={currentUser}
+          monorepo={projectBinding?.monorepo}
           mainBranchName={mainBranchName}
           willRefreshAutomatically={willRefreshAutomatically}
         />
index a2db9276d06832cf8e4602843677e70b26a2e518..285dc145251f4883b8f0de04b5ae56daa3614273 100644 (file)
@@ -29,12 +29,11 @@ import { mockComponent } from '../../../helpers/mocks/component';
 import { mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
 import { byRole, byText } from '../../../helpers/testSelector';
-import { ComponentPropsType } from '../../../helpers/testUtils';
 import { AlmKeys } from '../../../types/alm-settings';
 import { Feature } from '../../../types/features';
 import { Permissions } from '../../../types/permissions';
 import { SettingsKey } from '../../../types/settings';
-import TutorialSelection from '../TutorialSelection';
+import TutorialSelection, { TutorialSelectionProps } from '../TutorialSelection';
 import { TutorialModes } from '../types';
 
 jest.mock('../../../api/branches');
@@ -71,8 +70,14 @@ beforeEach(() => {
 const ui = {
   loading: byText('loading'),
   noScanRights: byText('onboarding.tutorial.no_scan_rights'),
+  monoRepoSecretInfo: byText('onboarding.tutorial.with.github_action.create_secret.monorepo_info'),
+  monoRepoYamlDocLink: byRole('link', {
+    name: 'onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions',
+  }),
   chooseTutorialLink: (mode: TutorialModes) =>
     byRole('link', { name: `onboarding.tutorial.choose_method.${mode}` }),
+  chooseBootstrapper: (bootstrapper: string) =>
+    byRole('radio', { name: `onboarding.build.${bootstrapper}` }),
 };
 
 it.each([
@@ -100,6 +105,54 @@ it.each([
   expect(screen.getByText(breadcrumbs)).toBeInTheDocument();
 });
 
+it('should properly detect and render GitHub monorepo-specific instructions for GitHub Actions', async () => {
+  almMock.handleSetProjectBinding(AlmKeys.GitHub, {
+    project: 'foo',
+    almSetting: 'foo',
+    repository: 'repo',
+    monorepo: true,
+  });
+  const user = userEvent.setup();
+  renderTutorialSelection({});
+  await waitOnDataLoaded();
+
+  await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get());
+
+  expect(ui.monoRepoSecretInfo.get()).toBeInTheDocument();
+
+  expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument();
+  await user.click(ui.chooseBootstrapper('maven').get());
+  expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument();
+
+  await user.click(ui.chooseBootstrapper('gradle').get());
+  expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument();
+
+  await user.click(ui.chooseBootstrapper('dotnet').get());
+  expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument();
+
+  await user.click(ui.chooseBootstrapper('other').get());
+  expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument();
+});
+
+it('should properly render GitHub project tutorials for GitHub Actions', async () => {
+  almMock.handleSetProjectBinding(AlmKeys.GitHub, {
+    project: 'foo',
+    almSetting: 'foo',
+    repository: 'repo',
+    monorepo: false,
+  });
+  const user = userEvent.setup();
+  renderTutorialSelection({});
+  await waitOnDataLoaded();
+
+  await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get());
+
+  expect(ui.monoRepoSecretInfo.query()).not.toBeInTheDocument();
+
+  await user.click(ui.chooseBootstrapper('maven').get());
+  expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument();
+});
+
 it.each([
   [
     AlmKeys.GitHub,
@@ -189,7 +242,7 @@ async function startLocalTutorial(user: UserEvent) {
 }
 
 function renderTutorialSelection(
-  props: Partial<ComponentPropsType<typeof TutorialSelection>> = {},
+  props: Partial<TutorialSelectionProps> = {},
   navigateTo: string = 'tutorials?id=bar',
 ) {
   return renderApp(
index 66980f58fa5313c033cf66f1c79dc1a7fc8ad60e..c059bc3de8292f40d4b5f984a5ad1eb2abf7db9d 100644 (file)
@@ -24,17 +24,19 @@ import SentenceWithFilename from './SentenceWithFilename';
 
 export interface DefaultProjectKeyProps {
   component: Component;
+  monorepo?: boolean;
 }
 
 const sonarProjectSnippet = (key: string) => `sonar.projectKey=${key}`;
 
 export default function DefaultProjectKey(props: DefaultProjectKeyProps) {
-  const { component } = props;
+  const { component, monorepo } = props;
+
   return (
     <NumberedListItem>
       <SentenceWithFilename
         filename="sonar-project.properties"
-        translationKey="onboarding.tutorial.other.project_key"
+        translationKey={`onboarding.tutorial.other.project_key${monorepo ? '.monorepo' : ''}`}
       />
       <CodeSnippet snippet={sonarProjectSnippet(component.key)} isOneLine className="sw-p-6" />
     </NumberedListItem>
index 341eaafaac98e34c97aa9bd6058940392fd3b1a1..63b119c5516b71c98b1a796de2c80785f3bf5b1f 100644 (file)
@@ -34,10 +34,11 @@ export interface AnalysisCommandProps extends WithAvailableFeaturesProps {
   buildTool: BuildTools;
   mainBranchName: string;
   component: Component;
+  monorepo?: boolean;
 }
 
-export function AnalysisCommand(props: AnalysisCommandProps) {
-  const { buildTool, component, mainBranchName } = props;
+export function AnalysisCommand(props: Readonly<AnalysisCommandProps>) {
+  const { buildTool, component, mainBranchName, monorepo } = props;
   const branchSupportEnabled = props.hasFeature(Feature.BranchSupport);
 
   switch (buildTool) {
@@ -46,6 +47,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
         <JavaMaven
           branchesEnabled={branchSupportEnabled}
           mainBranchName={mainBranchName}
+          monorepo={monorepo}
           component={component}
         />
       );
@@ -54,6 +56,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
         <Gradle
           branchesEnabled={branchSupportEnabled}
           mainBranchName={mainBranchName}
+          monorepo={monorepo}
           component={component}
         />
       );
@@ -62,6 +65,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
         <DotNet
           branchesEnabled={branchSupportEnabled}
           mainBranchName={mainBranchName}
+          monorepo={monorepo}
           component={component}
         />
       );
@@ -70,6 +74,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
         <CFamily
           branchesEnabled={branchSupportEnabled}
           mainBranchName={mainBranchName}
+          monorepo={monorepo}
           component={component}
         />
       );
@@ -78,9 +83,12 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
         <Others
           branchesEnabled={branchSupportEnabled}
           mainBranchName={mainBranchName}
+          monorepo={monorepo}
           component={component}
         />
       );
+    default:
+      return undefined;
   }
 }
 
index 73f6acffbcd4e6893cea9d71baed3935e15d9cd7..73ae10cb840927a8c1120324439a69b3a497190c 100644 (file)
@@ -33,27 +33,36 @@ export interface GitHubActionTutorialProps {
   baseUrl: string;
   component: Component;
   currentUser: LoggedInUser;
+  monorepo?: boolean;
   mainBranchName: string;
   willRefreshAutomatically?: boolean;
 }
 
 export default function GitHubActionTutorial(props: GitHubActionTutorialProps) {
   const [done, setDone] = React.useState<boolean>(false);
-  const { almBinding, baseUrl, currentUser, component, mainBranchName, willRefreshAutomatically } =
-    props;
+  const {
+    almBinding,
+    baseUrl,
+    currentUser,
+    component,
+    monorepo,
+    mainBranchName,
+    willRefreshAutomatically,
+  } = props;
+
+  const secretStepTitle = `onboarding.tutorial.with.github_action.create_secret.title${monorepo ? '.monorepo' : ''}`;
+
   return (
     <>
       <Title>{translate('onboarding.tutorial.with.github_ci.title')}</Title>
-
       <TutorialStepList className="sw-mb-8">
-        <TutorialStep
-          title={translate('onboarding.tutorial.with.github_action.create_secret.title')}
-        >
+        <TutorialStep title={translate(secretStepTitle)}>
           <SecretStep
             almBinding={almBinding}
             baseUrl={baseUrl}
             component={component}
             currentUser={currentUser}
+            monorepo={monorepo}
           />
         </TutorialStep>
         <TutorialStep title={translate('onboarding.tutorial.with.github_action.yaml.title')}>
@@ -63,6 +72,7 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) {
                 buildTool={buildTool}
                 mainBranchName={mainBranchName}
                 component={component}
+                monorepo={monorepo}
               />
             )}
           </YamlFileStep>
index 8c1850b08c8a69314d7fb8fe25ee6f7204a617db..3af2806b6fc3c85147dc5bbeca600e1b52ffc11e 100644 (file)
@@ -20,6 +20,7 @@
 import {
   BasicSeparator,
   ClipboardIconButton,
+  FlagMessage,
   NumberedList,
   NumberedListItem,
   StandoutLink,
@@ -41,10 +42,11 @@ export interface SecretStepProps {
   baseUrl: string;
   component: Component;
   currentUser: LoggedInUser;
+  monorepo?: boolean;
 }
 
 export default function SecretStep(props: SecretStepProps) {
-  const { almBinding, baseUrl, component, currentUser } = props;
+  const { almBinding, baseUrl, component, currentUser, monorepo } = props;
   const { data: projectBinding } = useProjectBindingQuery(component.key);
 
   return (
@@ -132,6 +134,11 @@ export default function SecretStep(props: SecretStepProps) {
           />
         </NumberedListItem>
       </NumberedList>
+      {monorepo && (
+        <FlagMessage variant="info" className="sw-block sw-w-fit sw-mt-4">
+          {translate('onboarding.tutorial.with.github_action.create_secret.monorepo_info')}
+        </FlagMessage>
+      )}
     </>
   );
 }
index 3121724aade228bc8413cbeb49ab4c497a4be30a..cfe75e6fe70e2783bf5c8ac48a28889eed0e8c03 100644 (file)
@@ -28,10 +28,12 @@ import GithubCFamilyExampleRepositories from '../../components/GithubCFamilyExam
 import RenderOptions from '../../components/RenderOptions';
 import { OSs, TutorialModes } from '../../types';
 import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
 
 export interface CFamilyProps {
   branchesEnabled?: boolean;
   mainBranchName: string;
+  monorepo?: boolean;
   component: Component;
 }
 
@@ -84,7 +86,7 @@ const STEPS = {
 };
 
 export default function CFamily(props: CFamilyProps) {
-  const { component, branchesEnabled, mainBranchName } = props;
+  const { component, branchesEnabled, mainBranchName, monorepo } = props;
   const [os, setOs] = React.useState<undefined | OSs>(OSs.Linux);
 
   const runsOn = {
@@ -94,7 +96,7 @@ export default function CFamily(props: CFamilyProps) {
   };
   return (
     <>
-      <DefaultProjectKey component={component} />
+      <DefaultProjectKey component={component} monorepo={monorepo} />
       <NumberedListItem>
         <span>{translate('onboarding.build.other.os')}</span>
         <RenderOptions
@@ -112,22 +114,25 @@ export default function CFamily(props: CFamilyProps) {
           />
         )}
       </NumberedListItem>
-      {os && (
-        <>
-          <CreateYmlFile
-            yamlFileName=".github/workflows/build.yml"
-            yamlTemplate={generateGitHubActionsYaml(
-              mainBranchName,
-              !!branchesEnabled,
-              runsOn[os],
-              STEPS[os],
-              `env:
+      {os &&
+        (monorepo ? (
+          <MonorepoDocLinkFallback />
+        ) : (
+          <>
+            <CreateYmlFile
+              yamlFileName=".github/workflows/build.yml"
+              yamlTemplate={generateGitHubActionsYaml(
+                mainBranchName,
+                !!branchesEnabled,
+                runsOn[os],
+                STEPS[os],
+                `env:
       BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory # Directory where build-wrapper output will be placed`,
-            )}
-          />
-          <CompilationInfo />
-        </>
-      )}
+              )}
+            />
+            <CompilationInfo />
+          </>
+        ))}
     </>
   );
 }
index 2bd5b8a90c88de053e9e5b3fd3d61e6f8e31e7dd..98f02aac2a4fcd6907245f28fa1214cd274033c6 100644 (file)
@@ -22,10 +22,12 @@ import { Component } from '../../../../types/types';
 import CreateYmlFile from '../../components/CreateYmlFile';
 import { GITHUB_ACTIONS_RUNS_ON_WINDOWS } from '../constants';
 import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
 
 export interface DotNetProps {
   branchesEnabled?: boolean;
   mainBranchName: string;
+  monorepo?: boolean;
   component: Component;
 }
 
@@ -63,7 +65,12 @@ function dotnetYamlSteps(projectKey: string) {
 }
 
 export default function DotNet(props: DotNetProps) {
-  const { component, branchesEnabled, mainBranchName } = props;
+  const { component, branchesEnabled, mainBranchName, monorepo } = props;
+
+  if (monorepo) {
+    return <MonorepoDocLinkFallback />;
+  }
+
   return (
     <CreateYmlFile
       yamlFileName=".github/workflows/build.yml"
index 75b562d06bfd3f00fe2fc984309e8c53164dad9a..0aaaa78d00f88cf2ff0fdaaa786629d1fba4be2b 100644 (file)
@@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile';
 import GradleBuild from '../../components/GradleBuild';
 import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants';
 import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
 
 export interface GradleProps {
   branchesEnabled?: boolean;
   mainBranchName: string;
+  monorepo?: boolean;
   component: Component;
 }
 
@@ -54,20 +56,25 @@ const GRADLE_YAML_STEPS = `
         run: ./gradlew build sonar --info`;
 
 export default function Gradle(props: GradleProps) {
-  const { component, branchesEnabled, mainBranchName } = props;
+  const { component, branchesEnabled, mainBranchName, monorepo } = props;
 
   return (
     <>
       <GradleBuild component={component} />
-      <CreateYmlFile
-        yamlFileName=".github/workflows/build.yml"
-        yamlTemplate={generateGitHubActionsYaml(
-          mainBranchName,
-          !!branchesEnabled,
-          GITHUB_ACTIONS_RUNS_ON_LINUX,
-          GRADLE_YAML_STEPS,
-        )}
-      />
+
+      {monorepo ? (
+        <MonorepoDocLinkFallback />
+      ) : (
+        <CreateYmlFile
+          yamlFileName=".github/workflows/build.yml"
+          yamlTemplate={generateGitHubActionsYaml(
+            mainBranchName,
+            !!branchesEnabled,
+            GITHUB_ACTIONS_RUNS_ON_LINUX,
+            GRADLE_YAML_STEPS,
+          )}
+        />
+      )}
     </>
   );
 }
index db2bf5c02b30de1a0414c5e23a9d10e388247538..2b3f89d7fcd386dc4b2af9cc2c92d2e3e49625ca 100644 (file)
@@ -22,10 +22,12 @@ import { Component } from '../../../../types/types';
 import CreateYmlFile from '../../components/CreateYmlFile';
 import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants';
 import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
 
 export interface JavaMavenProps {
   branchesEnabled?: boolean;
   mainBranchName: string;
+  monorepo?: boolean;
   component: Component;
 }
 
@@ -55,7 +57,12 @@ function mavenYamlSteps(projectKey: string, projectName: string) {
 }
 
 export default function JavaMaven(props: JavaMavenProps) {
-  const { component, branchesEnabled, mainBranchName } = props;
+  const { component, branchesEnabled, mainBranchName, monorepo } = props;
+
+  if (monorepo) {
+    return <MonorepoDocLinkFallback />;
+  }
+
   return (
     <CreateYmlFile
       yamlFileName=".github/workflows/build.yml"
diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx
new file mode 100644 (file)
index 0000000..6c0d3eb
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { NumberedListItem } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import DocumentationLink from '../../../common/DocumentationLink';
+
+const MONOREPO_DOC =
+  '/devops-platform-integration/github-integration/monorepo/#workflow-file-example';
+
+export default function MonorepoDocLinkFallback() {
+  return (
+    <NumberedListItem>
+      <DocumentationLink className="sw-mt-4" to={MONOREPO_DOC}>
+        {translate('onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions')}
+      </DocumentationLink>{' '}
+      {translate('onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions')}
+    </NumberedListItem>
+  );
+}
index e34071be0307a78c6156619c44aea49012e64213..b4e5bbc07fbd0efa6ec21a8c196bb2b78725e5a8 100644 (file)
@@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile';
 import DefaultProjectKey from '../../components/DefaultProjectKey';
 import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants';
 import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
 
 export interface OthersProps {
   branchesEnabled?: boolean;
   mainBranchName: string;
+  monorepo?: boolean;
   component: Component;
 }
 
@@ -55,19 +57,24 @@ function otherYamlSteps(branchesEnabled: boolean) {
 }
 
 export default function Others(props: OthersProps) {
-  const { component, branchesEnabled, mainBranchName } = props;
+  const { component, branchesEnabled, mainBranchName, monorepo } = props;
   return (
     <>
-      <DefaultProjectKey component={component} />
-      <CreateYmlFile
-        yamlFileName=".github/workflows/build.yml"
-        yamlTemplate={generateGitHubActionsYaml(
-          mainBranchName,
-          !!branchesEnabled,
-          GITHUB_ACTIONS_RUNS_ON_LINUX,
-          otherYamlSteps(!!branchesEnabled),
-        )}
-      />
+      <DefaultProjectKey component={component} monorepo={monorepo} />
+
+      {monorepo ? (
+        <MonorepoDocLinkFallback />
+      ) : (
+        <CreateYmlFile
+          yamlFileName=".github/workflows/build.yml"
+          yamlTemplate={generateGitHubActionsYaml(
+            mainBranchName,
+            !!branchesEnabled,
+            GITHUB_ACTIONS_RUNS_ON_LINUX,
+            otherYamlSteps(!!branchesEnabled),
+          )}
+        />
+      )}
     </>
   );
 }
index fae4bd0d7ac015c2af045ab8393cabb99df82d3a..996fdf94b24ceb23b4e87d5ad82e95168317bb98 100644 (file)
@@ -25,7 +25,7 @@ import {
   importGithubRepository,
   importGitlabProject,
 } from '../api/alm-integrations';
-import { createImportedProjects } from '../api/dop-translation';
+import { createBoundProject } from '../api/dop-translation';
 import { createProject } from '../api/project-management';
 import { ImportProjectParam } from '../apps/create/project/CreateProjectPage';
 import { CreateProjectModes } from '../apps/create/project/types';
@@ -34,20 +34,22 @@ export type MutationArg<AlmImport extends ImportProjectParam = ImportProjectPara
   AlmImport extends {
     creationMode: infer A;
     almSetting: string;
+    monorepo: false;
     projects: (infer R)[];
   }
-    ? { creationMode: A; almSetting: string } & R
+    ? { creationMode: A; almSetting: string; monorepo: false } & R
     :
         | {
             creationMode: CreateProjectModes.Manual;
             project: string;
             name: string;
             mainBranch: string;
+            monorepo: false;
           }
         | {
-            creationMode: CreateProjectModes.Monorepo;
+            creationMode: CreateProjectModes;
             devOpsPlatformSettingId: string;
-            monorepo: boolean;
+            monorepo: true;
             projectKey: string;
             projectName: string;
             repositoryIdentifier: string;
@@ -61,18 +63,21 @@ export function useImportProjectMutation() {
         newCodeDefinitionValue?: string;
       } & MutationArg,
     ) => {
-      if (data.creationMode === CreateProjectModes.GitHub) {
-        return importGithubRepository(data);
-      } else if (data.creationMode === CreateProjectModes.AzureDevOps) {
-        return importAzureRepository(data);
-      } else if (data.creationMode === CreateProjectModes.BitbucketCloud) {
-        return importBitbucketCloudRepository(data);
-      } else if (data.creationMode === CreateProjectModes.BitbucketServer) {
-        return importBitbucketServerProject(data);
-      } else if (data.creationMode === CreateProjectModes.GitLab) {
-        return importGitlabProject(data);
-      } else if (data.creationMode === CreateProjectModes.Monorepo) {
-        return createImportedProjects(data);
+      if (data.monorepo === true) {
+        return createBoundProject(data);
+      }
+
+      switch (data.creationMode) {
+        case CreateProjectModes.GitHub:
+          return importGithubRepository(data);
+        case CreateProjectModes.AzureDevOps:
+          return importAzureRepository(data);
+        case CreateProjectModes.BitbucketCloud:
+          return importBitbucketCloudRepository(data);
+        case CreateProjectModes.BitbucketServer:
+          return importBitbucketServerProject(data);
+        case CreateProjectModes.GitLab:
+          return importGitlabProject(data);
       }
 
       return createProject(data);
diff --git a/server/sonar-web/src/main/js/types/dop-translation.ts b/server/sonar-web/src/main/js/types/dop-translation.ts
new file mode 100644 (file)
index 0000000..916752b
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { AlmKeys } from './alm-settings';
+
+export interface DopSetting {
+  appId?: string;
+  id: string;
+  key: string;
+  type: AlmKeys;
+  url?: string;
+}
+
+export interface BoundProject {
+  devOpsPlatformSettingId: string;
+  monorepo: boolean;
+  newCodeDefinitionType?: string;
+  newCodeDefinitionValue?: string;
+  projectKey: string;
+  projectName: string;
+  repositoryIdentifier: string;
+}
index 10d1f1557d9a42ef331b75d0ebb245d9ab64a5f4..9e89edd1f4df1c422e0f3a09ac4e65481e5cc261 100644 (file)
@@ -417,6 +417,7 @@ alm.bitbucketcloud.short=Bitbucket
 alm.bitbucketcloud.long=Bitbucket Cloud
 alm.github=GitHub
 alm.github.short=GitHub
+alm.github.organization=organization
 alm.gitlab=GitLab
 alm.gitlab.short=GitLab
 alm.configuration.selector.label={0} configuration
@@ -1671,9 +1672,9 @@ settings.pr_decoration.binding.check_configuration.contact_admin=Please contact
 settings.pr_decoration.binding.check_configuration.success=Configuration valid.
 settings.pr_decoration.binding.form.name=Configuration name
 settings.pr_decoration.binding.form.name.help=Each DevOps Platform instance must be configured globally first, and given a unique name. Pick the instance your project is hosted on.
-settings.pr_decoration.binding.form.monorepo=Enable mono repository support
-settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a mono repository. {doc_link}
-settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a mono repository.
+settings.pr_decoration.binding.form.monorepo=Enable monorepository support
+settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a monorepository. {doc_link}
+settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a monorepository.
 settings.pr_decoration.binding.form.azure.project=Project name
 settings.pr_decoration.binding.form.azure.project.help=The name of the Azure DevOps project containing your repository. You can find this name on your project's Overview page.
 settings.pr_decoration.binding.form.azure.repository=Repository name
@@ -4407,16 +4408,21 @@ onboarding.create_project.bitbucketcloud.no_projects=No projects could be fetche
 onboarding.create_project.bitbucketcloud.link=See on Bitbucket
 onboarding.create_project.github.title=GitHub project onboarding
 onboarding.create_project.github.subtitle=Import repositories from one of your GitHub organizations.
+onboarding.create_project.github.subtitle.with_monorepo=Import repositories from one of your GitHub organizations or {monorepoSetupLink}.
+onboarding.create_project.github.subtitle.link=set up a monorepo
 onboarding.create_project.github.choose_organization=Choose an organization
+onboarding.create_project.github.choose_repository=Choose the repository
 onboarding.create_project.github.warning.message=Could not connect to GitHub. Please contact an administrator to configure GitHub integration.
 onboarding.create_project.github.warning.message_admin=Could not connect to GitHub. Please make sure the GitHub instance is correctly configured in the {link} to create a new project from a repository.
 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.github.no_projects=No projects could be fetched from GitHub. Contact your system administrator.
 onboarding.create_project.gitlab.title=Gitlab project onboarding
 onboarding.create_project.gitlab.subtitle=Import projects from one of your GitLab groups
 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
+onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator.
 onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding
 onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces
 onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected
@@ -4424,6 +4430,19 @@ onboarding.create_project.x_repository_created={count} {count, plural, one {repo
 onboarding.create_project.please_dont_leave=If you leave the page the import could fail. Are you sure you want to leave?
 onboarding.create_project.import_in_progress={count} of {total} projects imported. Please do not close this page until the import is complete.
 
+onboarding.create_project.monorepo.title={almName} monorepo project onboarding
+onboarding.create_project.monorepo.subtitle=Create multiple SonarQube projects corresponding to the same monorepo and bound to the same repository.
+onboarding.create_project.monorepo.doc_link=Learn more and get help setting up your monorepo
+onboarding.create_project.monorepo.choose_organization_and_repository.github=Choose the organization and the repository
+onboarding.create_project.monorepo.choose_dop_setting.github=Choose the GitHub configuration
+onboarding.create_project.monorepo.choose_organization.github=Choose the organization
+onboarding.create_project.monorepo.choose_organization.github.placeholder=List of organizations
+onboarding.create_project.monorepo.choose_repository.github=Choose the repository
+onboarding.create_project.monorepo.choose_repository.github.placeholder=List of repositories
+onboarding.create_project.monorepo.project_title=Create new projects
+onboarding.create_project.monorepo.add_project=Add new project
+onboarding.create_project.monorepo.remove_project=Remove project
+
 onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code
 onboarding.create_x_project.new_code_definition.title=Set up {count, plural, one {project} other {# projects}} for Clean as You Code
 onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code
@@ -4432,6 +4451,7 @@ onboarding.create_project.new_code_definition.description.link=Defining New Code
 onboarding.create_project.new_code_definition.create_x_projects=Create {count, plural, one {project} other {# projects}}
 onboarding.create_projects.new_code_definition.change_info=You can change this setting for each project individually at any time in the project administration settings.
 onboarding.create_project.success=Your {count, plural, one {project has} other {# projects have}} been created.
+onboarding.create_project.monorepo.success=Your monorepo has been set up successfully. {count, plural, one {1 new project was} other {# new projects were}} created
 onboarding.create_project.success.admin=Project {project_link} has been successfully created.
 onboarding.create_project.failure=Import of {count, plural, one {# project} other {# projects}} failed.
 
@@ -4550,6 +4570,7 @@ onboarding.tutorial.ci_outro.commit.why.no_branches=Each new push you make on yo
 onboarding.tutorial.ci_outro.refresh=This page will then refresh with your analysis results.
 onboarding.tutorial.ci_outro.refresh.why=If the page doesn't refresh after a while, please double-check the analysis configuration, and check your logs.
 onboarding.tutorial.other.project_key.sentence=Create a {file} file in your repository and paste the following code:
+onboarding.tutorial.other.project_key.monorepo.sentence=Create a {file} file at the root of your project and paste the following code:
 onboarding.tutorial.cfamilly.compilation_database_info=If you have trouble using the build wrapper, you can try using a {link}.
 onboarding.tutorial.cfamilly.compilation_database_info.link=compilation database
 onboarding.tutorial.cfamilly.speed_caching=You can also speed up your analysis by enabling {link}.
@@ -4598,6 +4619,10 @@ onboarding.tutorial.with.bitbucket_pipelines.variables.secured.sentence.secured=
 
 onboarding.tutorial.with.github_ci.title=Analyze your project with GitHub CI
 onboarding.tutorial.with.github_action.create_secret.title=Create GitHub Secrets
+onboarding.tutorial.with.github_action.create_secret.title.monorepo=Create GitHub Secrets (once per monorepository)
+onboarding.tutorial.with.github_action.create_secret.monorepo_info=If the secrets were created already for one of the projects in the mono repository, please skip this step
+onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions=(once per monorepository)
+onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions=See the documentation to create the Workflow YAML file at the root of your repository
 onboarding.tutorial.with.github_action.secret.intro=In your GitHub repository, go to {settings_secret} and create two new secrets:
 onboarding.tutorial.with.github_action.secret.intro.link=Settings > Secrets
 onboarding.tutorial.with.github_action.secret.name.sentence=In the {name} field, enter