]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19292 Warn users about non-CaYC compliant NCD options during project setup
authorPhilippe Perrin <philippe.perrin@sonarsource.com>
Tue, 16 May 2023 12:22:17 +0000 (14:22 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 16 May 2023 20:02:50 +0000 (20:02 +0000)
63 files changed:
server/sonar-web/src/main/js/api/mocks/NewCodePeriodsServiceMock.ts
server/sonar-web/src/main/js/apps/create/project/AlmSettingsInstanceDropdown.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/Azure/AzurePersonalAccessTokenForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectAccordion.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreateRender.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketCloudSearchForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketImportRepositoryForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketProjectAccordion.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketRepositories.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketSearchResults.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectAccordion.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketSearchResults.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/GitlabProjectSelectionForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css [deleted file]
server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/create/project/WrongBindingCountAlert.tsx [deleted file]
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__/GitHub-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/components/CreateProjectPageHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/components/InstanceNewCodeDefinitionComplianceWarning.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/components/PersonalAccessTokenForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/components/WrongBindingCountAlert.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/style.css
server/sonar-web/src/main/js/helpers/__tests__/periods-test.ts
server/sonar-web/src/main/js/helpers/periods.ts
server/sonar-web/src/main/js/types/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 042d78ad54ad8cd20753c13f678f1ea9d1d19c9b..43a20156098bf1c5ddc6ae2af808fb463cbaf4d6 100644 (file)
@@ -93,6 +93,10 @@ export default class NewCodePeriodsServiceMock {
     return this.reply({ newCodePeriods: this.#listBranchesNewCode });
   };
 
+  setNewCodePeriod = (newCodePeriod: NewCodePeriod) => {
+    this.#newCodePeriod = newCodePeriod;
+  };
+
   reset = () => {
     this.#newCodePeriod = cloneDeep(this.#defaultNewCodePeriod);
     this.#listBranchesNewCode = cloneDeep(this.#defaultListBranchesNewCode);
diff --git a/server/sonar-web/src/main/js/apps/create/project/AlmSettingsInstanceDropdown.tsx b/server/sonar-web/src/main/js/apps/create/project/AlmSettingsInstanceDropdown.tsx
deleted file mode 100644 (file)
index 34d348c..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import AlmSettingsInstanceSelector from '../../../components/devops-platform/AlmSettingsInstanceSelector';
-import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-
-export interface AlmSettingsInstanceDropdownProps {
-  almKey: AlmKeys;
-  almInstances?: AlmSettingsInstance[];
-  selectedAlmInstance?: AlmSettingsInstance;
-  onChangeConfig: (instance: AlmSettingsInstance) => void;
-}
-
-const MIN_SIZE_INSTANCES = 2;
-
-export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) {
-  const { almKey, almInstances, selectedAlmInstance } = props;
-  if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) {
-    return null;
-  }
-
-  const almKeyTranslation = hasMessage(`alm.${almKey}.long`)
-    ? `alm.${almKey}.long`
-    : `alm.${almKey}`;
-
-  return (
-    <div className="display-flex-column huge-spacer-bottom">
-      <label htmlFor="alm-config-selector" className="spacer-bottom">
-        {translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
-      </label>
-      <AlmSettingsInstanceSelector
-        instances={almInstances}
-        onChange={props.onChangeConfig}
-        initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
-        classNames="abs-width-400"
-        inputId="alm-config-selector"
-      />
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzurePersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzurePersonalAccessTokenForm.tsx
new file mode 100644 (file)
index 0000000..ba4c2a8
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+
+export interface AzurePersonalAccessTokenFormProps {
+  almSetting: AlmSettingsInstance;
+  onPersonalAccessTokenCreate: (token: string) => void;
+  submitting?: boolean;
+  validationFailed: boolean;
+  firstConnection?: boolean;
+}
+
+function getAzurePatUrl(url: string) {
+  return `${url.replace(/\/$/, '')}/_usersSettings/tokens`;
+}
+
+export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) {
+  const {
+    almSetting: { alm, url },
+    submitting = false,
+    validationFailed,
+    firstConnection,
+  } = props;
+
+  const [touched, setTouched] = React.useState(false);
+  React.useEffect(() => {
+    setTouched(false);
+  }, [submitting]);
+
+  const [token, setToken] = React.useState('');
+
+  const isInvalid = (validationFailed && !touched) || (touched && !token);
+
+  let errorMessage;
+  if (!token) {
+    errorMessage = translate('onboarding.create_project.pat_form.pat_required');
+  } else if (isInvalid) {
+    errorMessage = translate('onboarding.create_project.pat_incorrect', alm);
+  }
+
+  return (
+    <div className="boxed-group abs-width-600">
+      <div className="boxed-group-inner">
+        <h2>{translate('onboarding.create_project.pat_form.title', alm)}</h2>
+
+        <div className="big-spacer-top big-spacer-bottom">
+          <FormattedMessage
+            id="onboarding.create_project.pat_help.instructions"
+            defaultMessage={translate('onboarding.create_project.pat_help.instructions', alm)}
+            values={{
+              link: url ? (
+                <Link className="link-no-underline" to={getAzurePatUrl(url)} target="_blank">
+                  {translate('onboarding.create_project.pat_help.instructions.link', alm)}
+                </Link>
+              ) : (
+                translate('onboarding.create_project.pat_help.instructions.link', alm)
+              ),
+              scope: (
+                <strong>
+                  <em>Code (Read & Write)</em>
+                </strong>
+              ),
+            }}
+          />
+        </div>
+
+        {!firstConnection && (
+          <Alert className="big-spacer-right" variant="warning">
+            <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
+            <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
+          </Alert>
+        )}
+
+        <form
+          onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
+            e.preventDefault();
+            props.onPersonalAccessTokenCreate(token);
+          }}
+        >
+          <ValidationInput
+            error={errorMessage}
+            labelHtmlFor="personal_access_token"
+            isInvalid={isInvalid}
+            isValid={false}
+            label={translate('onboarding.create_project.enter_pat')}
+            required={true}
+          >
+            <input
+              autoFocus={true}
+              className={classNames('width-100 little-spacer-bottom', {
+                'is-invalid': isInvalid,
+              })}
+              id="personal_access_token"
+              minLength={1}
+              name="personal_access_token"
+              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
+                setToken(e.target.value);
+                setTouched(true);
+              }}
+              type="text"
+              value={token}
+            />
+          </ValidationInput>
+
+          <SubmitButton disabled={isInvalid || submitting || !touched}>
+            {translate('onboarding.create_project.pat_form.list_repositories')}
+          </SubmitButton>
+          <DeferredSpinner className="spacer-left" loading={submitting} />
+        </form>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectAccordion.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectAccordion.tsx
new file mode 100644 (file)
index 0000000..bf20280
--- /dev/null
@@ -0,0 +1,172 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import BoxedGroupAccordion from '../../../../components/controls/BoxedGroupAccordion';
+import ListFooter from '../../../../components/controls/ListFooter';
+import Radio from '../../../../components/controls/Radio';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { CreateProjectModes } from '../types';
+
+export interface AzureProjectAccordionProps {
+  importing: boolean;
+  loading: boolean;
+  onOpen: (key: string) => void;
+  onSelectRepository: (repository: AzureRepository) => void;
+  project: AzureProject;
+  repositories?: AzureRepository[];
+  searchQuery?: string;
+  selectedRepository?: AzureRepository;
+  startsOpen: boolean;
+}
+
+const PAGE_SIZE = 30;
+
+function highlight(text: string, term?: string, underline = false) {
+  if (!term || !text.toLowerCase().includes(term.toLowerCase())) {
+    return text;
+  }
+
+  // Capture only the first occurence by using a capturing group to get
+  // everything after the first occurence
+  const [pre, found, post] = text.split(new RegExp(`(${term})(.*)`, 'i'));
+  return (
+    <>
+      {pre}
+      <strong className={classNames({ underline })}>{found}</strong>
+      {post}
+    </>
+  );
+}
+
+export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
+  const {
+    importing,
+    loading,
+    startsOpen,
+    project,
+    repositories = [],
+    searchQuery,
+    selectedRepository,
+  } = props;
+
+  const [open, setOpen] = React.useState(startsOpen);
+  const handleClick = () => {
+    if (!open) {
+      props.onOpen(project.name);
+    }
+    setOpen(!open);
+  };
+
+  const [page, setPage] = React.useState(1);
+  const limitedRepositories = repositories.slice(0, page * PAGE_SIZE);
+
+  const isSelected = (repo: AzureRepository) =>
+    selectedRepository?.projectName === project.name && selectedRepository.name === repo.name;
+
+  return (
+    <BoxedGroupAccordion
+      className={classNames('big-spacer-bottom', {
+        open,
+      })}
+      onClick={handleClick}
+      open={open}
+      title={<h3 title={project.description}>{highlight(project.name, searchQuery, true)}</h3>}
+    >
+      {open && (
+        <DeferredSpinner loading={loading}>
+          {/* The extra loading guard is to prevent the flash of the Alert */}
+          {!loading && repositories.length === 0 ? (
+            <Alert variant="warning">
+              <FormattedMessage
+                defaultMessage={translate('onboarding.create_project.azure.no_repositories')}
+                id="onboarding.create_project.azure.no_repositories"
+                values={{
+                  link: (
+                    <Link
+                      to={{
+                        pathname: '/projects/create',
+                        search: queryToSearch({
+                          mode: CreateProjectModes.AzureDevOps,
+                          resetPat: 1,
+                        }),
+                      }}
+                    >
+                      {translate('onboarding.create_project.update_your_token')}
+                    </Link>
+                  ),
+                }}
+              />
+            </Alert>
+          ) : (
+            <>
+              <div className="display-flex-wrap">
+                {limitedRepositories.map((repo) => (
+                  <div
+                    className="create-project-azdo-repo display-flex-start spacer-bottom padded-right"
+                    key={repo.name}
+                  >
+                    {repo.sqProjectKey ? (
+                      <>
+                        <CheckIcon className="spacer-right" fill={colors.green} size={14} />
+                        <div className="overflow-hidden">
+                          <div className="little-spacer-bottom text-ellipsis">
+                            <Link to={getProjectUrl(repo.sqProjectKey)} title={repo.sqProjectName}>
+                              {highlight(repo.sqProjectName || repo.name, searchQuery)}
+                            </Link>
+                          </div>
+                          <em>{translate('onboarding.create_project.repository_imported')}</em>
+                        </div>
+                      </>
+                    ) : (
+                      <Radio
+                        checked={isSelected(repo)}
+                        className="overflow-hidden"
+                        alignLabel={true}
+                        disabled={importing}
+                        onCheck={() => props.onSelectRepository(repo)}
+                        value={repo.name}
+                      >
+                        <span title={repo.name}>{highlight(repo.name, searchQuery)}</span>
+                      </Radio>
+                    )}
+                  </div>
+                ))}
+              </div>
+              <ListFooter
+                count={limitedRepositories.length}
+                total={repositories.length}
+                loadMore={() => setPage((p) => p + 1)}
+              />
+            </>
+          )}
+        </DeferredSpinner>
+      )}
+    </BoxedGroupAccordion>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
new file mode 100644 (file)
index 0000000..8c8fb3f
--- /dev/null
@@ -0,0 +1,339 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import {
+  checkPersonalAccessTokenIsValid,
+  getAzureProjects,
+  getAzureRepositories,
+  importAzureRepository,
+  searchAzureRepositories,
+  setAlmPersonalAccessToken,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Dict } from '../../../../types/types';
+import { tokenExistedBefore } from '../utils';
+import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
+
+interface Props {
+  canAdmin: boolean;
+  loadingBindings: boolean;
+  onProjectCreate: (projectKey: string) => void;
+  almInstances: AlmSettingsInstance[];
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  importing: boolean;
+  loading: boolean;
+  loadingRepositories: Dict<boolean>;
+  patIsValid?: boolean;
+  projects?: AzureProject[];
+  repositories: Dict<AzureRepository[]>;
+  searching?: boolean;
+  searchResults?: AzureRepository[];
+  searchQuery?: string;
+  selectedRepository?: AzureRepository;
+  selectedAlmInstance?: AlmSettingsInstance;
+  submittingToken?: boolean;
+  tokenValidationFailed: boolean;
+  firstConnection?: boolean;
+}
+
+export default class AzureProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      // For now, we only handle a single instance. So we always use the first
+      // one from the list.
+      selectedAlmInstance: props.almInstances[0],
+      importing: false,
+      loading: false,
+      loadingRepositories: {},
+      repositories: {},
+      tokenValidationFailed: false,
+      firstConnection: false,
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchData();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchData = async () => {
+    this.setState({ loading: true });
+
+    const { patIsValid, error } = await this.checkPersonalAccessToken();
+
+    let projects: AzureProject[] | undefined;
+    if (patIsValid) {
+      projects = await this.fetchAzureProjects();
+    }
+
+    const { repositories } = this.state;
+
+    let firstProjectName: string;
+
+    if (projects && projects.length > 0) {
+      firstProjectName = projects[0].name;
+
+      this.setState(({ loadingRepositories }) => ({
+        loadingRepositories: { ...loadingRepositories, [firstProjectName]: true },
+      }));
+
+      const repos = await this.fetchAzureRepositories(firstProjectName);
+      repositories[firstProjectName] = repos;
+    }
+
+    if (this.mounted) {
+      this.setState(({ loadingRepositories }) => {
+        if (firstProjectName) {
+          loadingRepositories[firstProjectName] = false;
+        }
+
+        return {
+          patIsValid,
+          loading: false,
+          loadingRepositories: { ...loadingRepositories },
+          projects,
+          repositories,
+          firstConnection: tokenExistedBefore(error),
+        };
+      });
+    }
+  };
+
+  fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve(undefined);
+    }
+
+    return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects);
+  };
+
+  fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve([]);
+    }
+
+    return getAzureRepositories(selectedAlmInstance.key, projectName)
+      .then(({ repositories }) => repositories)
+      .catch(() => []);
+  };
+
+  cleanUrl = () => {
+    const { location, router } = this.props;
+    delete location.query.resetPat;
+    router.replace(location);
+  };
+
+  handleOpenProject = async (projectName: string) => {
+    if (this.state.searchResults) {
+      return;
+    }
+
+    this.setState(({ loadingRepositories }) => ({
+      loadingRepositories: { ...loadingRepositories, [projectName]: true },
+    }));
+
+    const projectRepos = await this.fetchAzureRepositories(projectName);
+
+    this.setState(({ loadingRepositories, repositories }) => ({
+      loadingRepositories: { ...loadingRepositories, [projectName]: false },
+      repositories: { ...repositories, [projectName]: projectRepos },
+    }));
+  };
+
+  handleSearchRepositories = async (searchQuery: string) => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return;
+    }
+
+    if (searchQuery.length === 0) {
+      this.setState({ searchResults: undefined, searchQuery: undefined });
+      return;
+    }
+
+    this.setState({ searching: true });
+
+    const searchResults: AzureRepository[] = await searchAzureRepositories(
+      selectedAlmInstance.key,
+      searchQuery
+    )
+      .then(({ repositories }) => repositories)
+      .catch(() => []);
+
+    if (this.mounted) {
+      this.setState({
+        searching: false,
+        searchResults,
+        searchQuery,
+      });
+    }
+  };
+
+  handleImportRepository = async () => {
+    const { selectedRepository, selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance || !selectedRepository) {
+      return;
+    }
+
+    this.setState({ importing: true });
+
+    const createdProject = await importAzureRepository(
+      selectedAlmInstance.key,
+      selectedRepository.projectName,
+      selectedRepository.name
+    )
+      .then(({ project }) => project)
+      .catch(() => undefined);
+
+    if (this.mounted) {
+      this.setState({ importing: false });
+      if (createdProject) {
+        this.props.onProjectCreate(createdProject.key);
+      }
+    }
+  };
+
+  handleSelectRepository = (selectedRepository: AzureRepository) => {
+    this.setState({ selectedRepository });
+  };
+
+  checkPersonalAccessToken = () => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve({ patIsValid: false, error: '' });
+    }
+
+    return checkPersonalAccessTokenIsValid(selectedAlmInstance.key).then(({ status, error }) => {
+      return { patIsValid: status, error };
+    });
+  };
+
+  handlePersonalAccessTokenCreate = async (token: string) => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance || token.length < 1) {
+      return;
+    }
+
+    this.setState({ submittingToken: true, tokenValidationFailed: false });
+
+    try {
+      await setAlmPersonalAccessToken(selectedAlmInstance.key, token);
+      const { patIsValid } = await this.checkPersonalAccessToken();
+
+      if (this.mounted) {
+        this.setState({
+          submittingToken: false,
+          patIsValid,
+          tokenValidationFailed: !patIsValid,
+        });
+
+        if (patIsValid) {
+          this.cleanUrl();
+          this.fetchData();
+        }
+      }
+    } catch (e) {
+      if (this.mounted) {
+        this.setState({ submittingToken: false });
+      }
+    }
+  };
+
+  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+    this.setState(
+      { selectedAlmInstance: instance, searchResults: undefined, searchQuery: '' },
+      () => this.fetchData()
+    );
+  };
+
+  render() {
+    const { canAdmin, loadingBindings, location, almInstances } = this.props;
+    const {
+      importing,
+      loading,
+      loadingRepositories,
+      patIsValid,
+      projects,
+      repositories,
+      searching,
+      searchResults,
+      searchQuery,
+      selectedRepository,
+      selectedAlmInstance,
+      submittingToken,
+      tokenValidationFailed,
+      firstConnection,
+    } = this.state;
+
+    return (
+      <AzureCreateProjectRenderer
+        canAdmin={canAdmin}
+        importing={importing}
+        loading={loading || loadingBindings}
+        loadingRepositories={loadingRepositories}
+        onImportRepository={this.handleImportRepository}
+        onOpenProject={this.handleOpenProject}
+        onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
+        onSearch={this.handleSearchRepositories}
+        onSelectRepository={this.handleSelectRepository}
+        projects={projects}
+        repositories={repositories}
+        searching={searching}
+        searchResults={searchResults}
+        searchQuery={searchQuery}
+        selectedRepository={selectedRepository}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
+        submittingToken={submittingToken}
+        tokenValidationFailed={tokenValidationFailed}
+        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+        firstConnection={firstConnection}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx
new file mode 100644 (file)
index 0000000..cbb0514
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import { Button } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { getGlobalSettingsUrl } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Dict } from '../../../../types/types';
+import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
+import AzureProjectsList from './AzureProjectsList';
+
+export interface AzureProjectCreateRendererProps {
+  canAdmin?: boolean;
+  importing: boolean;
+  loading: boolean;
+  loadingRepositories: Dict<boolean>;
+  onImportRepository: () => void;
+  onOpenProject: (key: string) => void;
+  onPersonalAccessTokenCreate: (token: string) => void;
+  onSearch: (query: string) => void;
+  onSelectRepository: (repository: AzureRepository) => void;
+  projects?: AzureProject[];
+  repositories: Dict<AzureRepository[]>;
+  searching?: boolean;
+  searchResults?: AzureRepository[];
+  searchQuery?: string;
+  selectedRepository?: AzureRepository;
+  almInstances?: AlmSettingsInstance[];
+  selectedAlmInstance?: AlmSettingsInstance;
+  showPersonalAccessTokenForm?: boolean;
+  submittingToken?: boolean;
+  tokenValidationFailed: boolean;
+  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+  firstConnection?: boolean;
+}
+
+export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
+  const {
+    canAdmin,
+    importing,
+    loading,
+    loadingRepositories,
+    projects,
+    repositories,
+    searching,
+    searchResults,
+    searchQuery,
+    selectedRepository,
+    almInstances,
+    showPersonalAccessTokenForm,
+    submittingToken,
+    tokenValidationFailed,
+    selectedAlmInstance,
+    firstConnection,
+  } = props;
+
+  const showCountError = !loading && (!almInstances || almInstances?.length === 0);
+  const settingIsValid = selectedAlmInstance && selectedAlmInstance.url;
+  const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url;
+
+  return (
+    <>
+      <CreateProjectPageHeader
+        additionalActions={
+          !showPersonalAccessTokenForm &&
+          settingIsValid && (
+            <div className="display-flex-center pull-right">
+              <DeferredSpinner className="spacer-right" loading={importing} />
+              <Button
+                className="button-large button-primary"
+                disabled={!selectedRepository || importing}
+                onClick={props.onImportRepository}
+              >
+                {translate('onboarding.create_project.import_selected_repo')}
+              </Button>
+            </div>
+          )
+        }
+        title={
+          <span className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="24"
+              src={`${getBaseUrl()}/images/alm/azure.svg`}
+            />
+            {translate('onboarding.create_project.azure.title')}
+          </span>
+        }
+      />
+
+      <AlmSettingsInstanceDropdown
+        almKey={AlmKeys.Azure}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onChangeConfig={props.onSelectedAlmInstanceChange}
+      />
+
+      {loading && <i className="spinner" />}
+
+      {showUrlError && (
+        <Alert variant="error">
+          {canAdmin ? (
+            <FormattedMessage
+              defaultMessage={translate('onboarding.create_project.azure.no_url.admin')}
+              id="onboarding.create_project.azure.no_url.admin"
+              values={{
+                alm: translate('onboarding.alm', AlmKeys.Azure),
+                url: (
+                  <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
+                    {translate('settings.page')}
+                  </Link>
+                ),
+              }}
+            />
+          ) : (
+            translate('onboarding.create_project.azure.no_url')
+          )}
+        </Alert>
+      )}
+
+      {showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />}
+
+      {!loading &&
+        selectedAlmInstance &&
+        selectedAlmInstance.url &&
+        (showPersonalAccessTokenForm ? (
+          <div>
+            <AzurePersonalAccessTokenForm
+              almSetting={selectedAlmInstance}
+              onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate}
+              submitting={submittingToken}
+              validationFailed={tokenValidationFailed}
+              firstConnection={firstConnection}
+            />
+          </div>
+        ) : (
+          <>
+            <InstanceNewCodeDefinitionComplianceWarning />
+
+            <div className="huge-spacer-bottom">
+              <SearchBox
+                onChange={props.onSearch}
+                placeholder={translate('onboarding.create_project.search_projects_repositories')}
+              />
+            </div>
+            <DeferredSpinner loading={Boolean(searching)}>
+              <AzureProjectsList
+                importing={importing}
+                loadingRepositories={loadingRepositories}
+                onOpenProject={props.onOpenProject}
+                onSelectRepository={props.onSelectRepository}
+                projects={projects}
+                repositories={repositories}
+                searchResults={searchResults}
+                searchQuery={searchQuery}
+                selectedRepository={selectedRepository}
+              />
+            </DeferredSpinner>
+          </>
+        ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx
new file mode 100644 (file)
index 0000000..a393fe2
--- /dev/null
@@ -0,0 +1,145 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { uniqBy } from 'lodash';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { queryToSearch } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { Dict } from '../../../../types/types';
+import { CreateProjectModes } from '../types';
+import AzureProjectAccordion from './AzureProjectAccordion';
+
+export interface AzureProjectsListProps {
+  importing: boolean;
+  loadingRepositories: Dict<boolean>;
+  onOpenProject: (key: string) => void;
+  onSelectRepository: (repository: AzureRepository) => void;
+  projects?: AzureProject[];
+  repositories: Dict<AzureRepository[]>;
+  searchResults?: AzureRepository[];
+  searchQuery?: string;
+  selectedRepository?: AzureRepository;
+}
+
+const PAGE_SIZE = 10;
+
+export default function AzureProjectsList(props: AzureProjectsListProps) {
+  const {
+    importing,
+    loadingRepositories,
+    projects = [],
+    repositories,
+    searchResults,
+    searchQuery,
+    selectedRepository,
+  } = props;
+
+  const [page, setPage] = React.useState(1);
+
+  if (searchResults && searchResults.length === 0) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        {translate('onboarding.create_project.azure.no_results')}
+      </Alert>
+    );
+  }
+
+  if (projects.length === 0) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.azure.no_projects')}
+          id="onboarding.create_project.azure.no_projects"
+          values={{
+            link: (
+              <Link
+                to={{
+                  pathname: '/projects/create',
+                  search: queryToSearch({ mode: CreateProjectModes.AzureDevOps, resetPat: 1 }),
+                }}
+              >
+                {translate('onboarding.create_project.update_your_token')}
+              </Link>
+            ),
+          }}
+        />
+      </Alert>
+    );
+  }
+
+  let filteredProjects: AzureProject[];
+  if (searchResults !== undefined) {
+    filteredProjects = uniqBy(
+      searchResults.map((r) => {
+        return (
+          projects.find((p) => p.name === r.projectName) || {
+            name: r.projectName,
+            description: translateWithParameters(
+              'onboarding.create_project.azure.search_results_for_project_X',
+              r.projectName
+            ),
+          }
+        );
+      }),
+      'name'
+    );
+  } else {
+    filteredProjects = projects;
+  }
+
+  const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE);
+
+  // Add a suffix to the key to force react to not reuse AzureProjectAccordions between
+  // search results and project exploration
+  const keySuffix = searchResults ? ' - result' : '';
+
+  return (
+    <div>
+      {displayedProjects.map((p, i) => (
+        <AzureProjectAccordion
+          key={`${p.name}${keySuffix}`}
+          importing={importing}
+          loading={Boolean(loadingRepositories[p.name])}
+          onOpen={props.onOpenProject}
+          onSelectRepository={props.onSelectRepository}
+          project={p}
+          repositories={
+            searchResults
+              ? searchResults.filter((s) => s.projectName === p.name)
+              : repositories[p.name]
+          }
+          selectedRepository={selectedRepository}
+          searchQuery={searchQuery}
+          startsOpen={searchResults !== undefined || i === 0}
+        />
+      ))}
+
+      <ListFooter
+        count={displayedProjects.length}
+        loadMore={() => setPage((p) => p + 1)}
+        total={filteredProjects.length}
+      />
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx
deleted file mode 100644 (file)
index 24a81c3..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { SubmitButton } from '../../../components/controls/buttons';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-
-export interface AzurePersonalAccessTokenFormProps {
-  almSetting: AlmSettingsInstance;
-  onPersonalAccessTokenCreate: (token: string) => void;
-  submitting?: boolean;
-  validationFailed: boolean;
-  firstConnection?: boolean;
-}
-
-function getAzurePatUrl(url: string) {
-  return `${url.replace(/\/$/, '')}/_usersSettings/tokens`;
-}
-
-export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) {
-  const {
-    almSetting: { alm, url },
-    submitting = false,
-    validationFailed,
-    firstConnection,
-  } = props;
-
-  const [touched, setTouched] = React.useState(false);
-  React.useEffect(() => {
-    setTouched(false);
-  }, [submitting]);
-
-  const [token, setToken] = React.useState('');
-
-  const isInvalid = (validationFailed && !touched) || (touched && !token);
-
-  let errorMessage;
-  if (!token) {
-    errorMessage = translate('onboarding.create_project.pat_form.pat_required');
-  } else if (isInvalid) {
-    errorMessage = translate('onboarding.create_project.pat_incorrect', alm);
-  }
-
-  return (
-    <div className="boxed-group abs-width-600">
-      <div className="boxed-group-inner">
-        <h2>{translate('onboarding.create_project.pat_form.title', alm)}</h2>
-
-        <div className="big-spacer-top big-spacer-bottom">
-          <FormattedMessage
-            id="onboarding.create_project.pat_help.instructions"
-            defaultMessage={translate('onboarding.create_project.pat_help.instructions', alm)}
-            values={{
-              link: url ? (
-                <Link className="link-no-underline" to={getAzurePatUrl(url)} target="_blank">
-                  {translate('onboarding.create_project.pat_help.instructions.link', alm)}
-                </Link>
-              ) : (
-                translate('onboarding.create_project.pat_help.instructions.link', alm)
-              ),
-              scope: (
-                <strong>
-                  <em>Code (Read & Write)</em>
-                </strong>
-              ),
-            }}
-          />
-        </div>
-
-        {!firstConnection && (
-          <Alert className="big-spacer-right" variant="warning">
-            <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
-            <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
-          </Alert>
-        )}
-
-        <form
-          onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
-            e.preventDefault();
-            props.onPersonalAccessTokenCreate(token);
-          }}
-        >
-          <ValidationInput
-            error={errorMessage}
-            labelHtmlFor="personal_access_token"
-            isInvalid={isInvalid}
-            isValid={false}
-            label={translate('onboarding.create_project.enter_pat')}
-            required={true}
-          >
-            <input
-              autoFocus={true}
-              className={classNames('width-100 little-spacer-bottom', {
-                'is-invalid': isInvalid,
-              })}
-              id="personal_access_token"
-              minLength={1}
-              name="personal_access_token"
-              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
-                setToken(e.target.value);
-                setTouched(true);
-              }}
-              type="text"
-              value={token}
-            />
-          </ValidationInput>
-
-          <SubmitButton disabled={isInvalid || submitting || !touched}>
-            {translate('onboarding.create_project.pat_form.list_repositories')}
-          </SubmitButton>
-          <DeferredSpinner className="spacer-left" loading={submitting} />
-        </form>
-      </div>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx
deleted file mode 100644 (file)
index 0d96324..0000000
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
-import ListFooter from '../../../components/controls/ListFooter';
-import Radio from '../../../components/controls/Radio';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { CreateProjectModes } from './types';
-
-export interface AzureProjectAccordionProps {
-  importing: boolean;
-  loading: boolean;
-  onOpen: (key: string) => void;
-  onSelectRepository: (repository: AzureRepository) => void;
-  project: AzureProject;
-  repositories?: AzureRepository[];
-  searchQuery?: string;
-  selectedRepository?: AzureRepository;
-  startsOpen: boolean;
-}
-
-const PAGE_SIZE = 30;
-
-function highlight(text: string, term?: string, underline = false) {
-  if (!term || !text.toLowerCase().includes(term.toLowerCase())) {
-    return text;
-  }
-
-  // Capture only the first occurence by using a capturing group to get
-  // everything after the first occurence
-  const [pre, found, post] = text.split(new RegExp(`(${term})(.*)`, 'i'));
-  return (
-    <>
-      {pre}
-      <strong className={classNames({ underline })}>{found}</strong>
-      {post}
-    </>
-  );
-}
-
-export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
-  const {
-    importing,
-    loading,
-    startsOpen,
-    project,
-    repositories = [],
-    searchQuery,
-    selectedRepository,
-  } = props;
-
-  const [open, setOpen] = React.useState(startsOpen);
-  const handleClick = () => {
-    if (!open) {
-      props.onOpen(project.name);
-    }
-    setOpen(!open);
-  };
-
-  const [page, setPage] = React.useState(1);
-  const limitedRepositories = repositories.slice(0, page * PAGE_SIZE);
-
-  const isSelected = (repo: AzureRepository) =>
-    selectedRepository?.projectName === project.name && selectedRepository.name === repo.name;
-
-  return (
-    <BoxedGroupAccordion
-      className={classNames('big-spacer-bottom', {
-        open,
-      })}
-      onClick={handleClick}
-      open={open}
-      title={<h3 title={project.description}>{highlight(project.name, searchQuery, true)}</h3>}
-    >
-      {open && (
-        <DeferredSpinner loading={loading}>
-          {/* The extra loading guard is to prevent the flash of the Alert */}
-          {!loading && repositories.length === 0 ? (
-            <Alert variant="warning">
-              <FormattedMessage
-                defaultMessage={translate('onboarding.create_project.azure.no_repositories')}
-                id="onboarding.create_project.azure.no_repositories"
-                values={{
-                  link: (
-                    <Link
-                      to={{
-                        pathname: '/projects/create',
-                        search: queryToSearch({
-                          mode: CreateProjectModes.AzureDevOps,
-                          resetPat: 1,
-                        }),
-                      }}
-                    >
-                      {translate('onboarding.create_project.update_your_token')}
-                    </Link>
-                  ),
-                }}
-              />
-            </Alert>
-          ) : (
-            <>
-              <div className="display-flex-wrap">
-                {limitedRepositories.map((repo) => (
-                  <div
-                    className="create-project-azdo-repo display-flex-start spacer-bottom padded-right"
-                    key={repo.name}
-                  >
-                    {repo.sqProjectKey ? (
-                      <>
-                        <CheckIcon className="spacer-right" fill={colors.green} size={14} />
-                        <div className="overflow-hidden">
-                          <div className="little-spacer-bottom text-ellipsis">
-                            <Link to={getProjectUrl(repo.sqProjectKey)} title={repo.sqProjectName}>
-                              {highlight(repo.sqProjectName || repo.name, searchQuery)}
-                            </Link>
-                          </div>
-                          <em>{translate('onboarding.create_project.repository_imported')}</em>
-                        </div>
-                      </>
-                    ) : (
-                      <Radio
-                        checked={isSelected(repo)}
-                        className="overflow-hidden"
-                        alignLabel={true}
-                        disabled={importing}
-                        onCheck={() => props.onSelectRepository(repo)}
-                        value={repo.name}
-                      >
-                        <span title={repo.name}>{highlight(repo.name, searchQuery)}</span>
-                      </Radio>
-                    )}
-                  </div>
-                ))}
-              </div>
-              <ListFooter
-                count={limitedRepositories.length}
-                total={repositories.length}
-                loadMore={() => setPage((p) => p + 1)}
-              />
-            </>
-          )}
-        </DeferredSpinner>
-      )}
-    </BoxedGroupAccordion>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx
deleted file mode 100644 (file)
index 94a98a3..0000000
+++ /dev/null
@@ -1,339 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import {
-  checkPersonalAccessTokenIsValid,
-  getAzureProjects,
-  getAzureRepositories,
-  importAzureRepository,
-  searchAzureRepositories,
-  setAlmPersonalAccessToken,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Dict } from '../../../types/types';
-import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
-import { tokenExistedBefore } from './utils';
-
-interface Props {
-  canAdmin: boolean;
-  loadingBindings: boolean;
-  onProjectCreate: (projectKey: string) => void;
-  almInstances: AlmSettingsInstance[];
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  importing: boolean;
-  loading: boolean;
-  loadingRepositories: Dict<boolean>;
-  patIsValid?: boolean;
-  projects?: AzureProject[];
-  repositories: Dict<AzureRepository[]>;
-  searching?: boolean;
-  searchResults?: AzureRepository[];
-  searchQuery?: string;
-  selectedRepository?: AzureRepository;
-  selectedAlmInstance?: AlmSettingsInstance;
-  submittingToken?: boolean;
-  tokenValidationFailed: boolean;
-  firstConnection?: boolean;
-}
-
-export default class AzureProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      // For now, we only handle a single instance. So we always use the first
-      // one from the list.
-      selectedAlmInstance: props.almInstances[0],
-      importing: false,
-      loading: false,
-      loadingRepositories: {},
-      repositories: {},
-      tokenValidationFailed: false,
-      firstConnection: false,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchData();
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchData = async () => {
-    this.setState({ loading: true });
-
-    const { patIsValid, error } = await this.checkPersonalAccessToken();
-
-    let projects: AzureProject[] | undefined;
-    if (patIsValid) {
-      projects = await this.fetchAzureProjects();
-    }
-
-    const { repositories } = this.state;
-
-    let firstProjectName: string;
-
-    if (projects && projects.length > 0) {
-      firstProjectName = projects[0].name;
-
-      this.setState(({ loadingRepositories }) => ({
-        loadingRepositories: { ...loadingRepositories, [firstProjectName]: true },
-      }));
-
-      const repos = await this.fetchAzureRepositories(firstProjectName);
-      repositories[firstProjectName] = repos;
-    }
-
-    if (this.mounted) {
-      this.setState(({ loadingRepositories }) => {
-        if (firstProjectName) {
-          loadingRepositories[firstProjectName] = false;
-        }
-
-        return {
-          patIsValid,
-          loading: false,
-          loadingRepositories: { ...loadingRepositories },
-          projects,
-          repositories,
-          firstConnection: tokenExistedBefore(error),
-        };
-      });
-    }
-  };
-
-  fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
-
-    return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects);
-  };
-
-  fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve([]);
-    }
-
-    return getAzureRepositories(selectedAlmInstance.key, projectName)
-      .then(({ repositories }) => repositories)
-      .catch(() => []);
-  };
-
-  cleanUrl = () => {
-    const { location, router } = this.props;
-    delete location.query.resetPat;
-    router.replace(location);
-  };
-
-  handleOpenProject = async (projectName: string) => {
-    if (this.state.searchResults) {
-      return;
-    }
-
-    this.setState(({ loadingRepositories }) => ({
-      loadingRepositories: { ...loadingRepositories, [projectName]: true },
-    }));
-
-    const projectRepos = await this.fetchAzureRepositories(projectName);
-
-    this.setState(({ loadingRepositories, repositories }) => ({
-      loadingRepositories: { ...loadingRepositories, [projectName]: false },
-      repositories: { ...repositories, [projectName]: projectRepos },
-    }));
-  };
-
-  handleSearchRepositories = async (searchQuery: string) => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return;
-    }
-
-    if (searchQuery.length === 0) {
-      this.setState({ searchResults: undefined, searchQuery: undefined });
-      return;
-    }
-
-    this.setState({ searching: true });
-
-    const searchResults: AzureRepository[] = await searchAzureRepositories(
-      selectedAlmInstance.key,
-      searchQuery
-    )
-      .then(({ repositories }) => repositories)
-      .catch(() => []);
-
-    if (this.mounted) {
-      this.setState({
-        searching: false,
-        searchResults,
-        searchQuery,
-      });
-    }
-  };
-
-  handleImportRepository = async () => {
-    const { selectedRepository, selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance || !selectedRepository) {
-      return;
-    }
-
-    this.setState({ importing: true });
-
-    const createdProject = await importAzureRepository(
-      selectedAlmInstance.key,
-      selectedRepository.projectName,
-      selectedRepository.name
-    )
-      .then(({ project }) => project)
-      .catch(() => undefined);
-
-    if (this.mounted) {
-      this.setState({ importing: false });
-      if (createdProject) {
-        this.props.onProjectCreate(createdProject.key);
-      }
-    }
-  };
-
-  handleSelectRepository = (selectedRepository: AzureRepository) => {
-    this.setState({ selectedRepository });
-  };
-
-  checkPersonalAccessToken = () => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve({ patIsValid: false, error: '' });
-    }
-
-    return checkPersonalAccessTokenIsValid(selectedAlmInstance.key).then(({ status, error }) => {
-      return { patIsValid: status, error };
-    });
-  };
-
-  handlePersonalAccessTokenCreate = async (token: string) => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance || token.length < 1) {
-      return;
-    }
-
-    this.setState({ submittingToken: true, tokenValidationFailed: false });
-
-    try {
-      await setAlmPersonalAccessToken(selectedAlmInstance.key, token);
-      const { patIsValid } = await this.checkPersonalAccessToken();
-
-      if (this.mounted) {
-        this.setState({
-          submittingToken: false,
-          patIsValid,
-          tokenValidationFailed: !patIsValid,
-        });
-
-        if (patIsValid) {
-          this.cleanUrl();
-          this.fetchData();
-        }
-      }
-    } catch (e) {
-      if (this.mounted) {
-        this.setState({ submittingToken: false });
-      }
-    }
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState(
-      { selectedAlmInstance: instance, searchResults: undefined, searchQuery: '' },
-      () => this.fetchData()
-    );
-  };
-
-  render() {
-    const { canAdmin, loadingBindings, location, almInstances } = this.props;
-    const {
-      importing,
-      loading,
-      loadingRepositories,
-      patIsValid,
-      projects,
-      repositories,
-      searching,
-      searchResults,
-      searchQuery,
-      selectedRepository,
-      selectedAlmInstance,
-      submittingToken,
-      tokenValidationFailed,
-      firstConnection,
-    } = this.state;
-
-    return (
-      <AzureCreateProjectRenderer
-        canAdmin={canAdmin}
-        importing={importing}
-        loading={loading || loadingBindings}
-        loadingRepositories={loadingRepositories}
-        onImportRepository={this.handleImportRepository}
-        onOpenProject={this.handleOpenProject}
-        onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
-        onSearch={this.handleSearchRepositories}
-        onSelectRepository={this.handleSelectRepository}
-        projects={projects}
-        repositories={repositories}
-        searching={searching}
-        searchResults={searchResults}
-        searchQuery={searchQuery}
-        selectedRepository={selectedRepository}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
-        submittingToken={submittingToken}
-        tokenValidationFailed={tokenValidationFailed}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-        firstConnection={firstConnection}
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx
deleted file mode 100644 (file)
index d3ad3e4..0000000
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import SearchBox from '../../../components/controls/SearchBox';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { getGlobalSettingsUrl } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Dict } from '../../../types/types';
-import { ALM_INTEGRATION_CATEGORY } from '../../settings/constants';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
-import AzureProjectsList from './AzureProjectsList';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface AzureProjectCreateRendererProps {
-  canAdmin?: boolean;
-  importing: boolean;
-  loading: boolean;
-  loadingRepositories: Dict<boolean>;
-  onImportRepository: () => void;
-  onOpenProject: (key: string) => void;
-  onPersonalAccessTokenCreate: (token: string) => void;
-  onSearch: (query: string) => void;
-  onSelectRepository: (repository: AzureRepository) => void;
-  projects?: AzureProject[];
-  repositories: Dict<AzureRepository[]>;
-  searching?: boolean;
-  searchResults?: AzureRepository[];
-  searchQuery?: string;
-  selectedRepository?: AzureRepository;
-  almInstances?: AlmSettingsInstance[];
-  selectedAlmInstance?: AlmSettingsInstance;
-  showPersonalAccessTokenForm?: boolean;
-  submittingToken?: boolean;
-  tokenValidationFailed: boolean;
-  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-  firstConnection?: boolean;
-}
-
-export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
-  const {
-    canAdmin,
-    importing,
-    loading,
-    loadingRepositories,
-    projects,
-    repositories,
-    searching,
-    searchResults,
-    searchQuery,
-    selectedRepository,
-    almInstances,
-    showPersonalAccessTokenForm,
-    submittingToken,
-    tokenValidationFailed,
-    selectedAlmInstance,
-    firstConnection,
-  } = props;
-
-  const showCountError = !loading && (!almInstances || almInstances?.length === 0);
-  const settingIsValid = selectedAlmInstance && selectedAlmInstance.url;
-  const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url;
-
-  return (
-    <>
-      <CreateProjectPageHeader
-        additionalActions={
-          !showPersonalAccessTokenForm &&
-          settingIsValid && (
-            <div className="display-flex-center pull-right">
-              <DeferredSpinner className="spacer-right" loading={importing} />
-              <Button
-                className="button-large button-primary"
-                disabled={!selectedRepository || importing}
-                onClick={props.onImportRepository}
-              >
-                {translate('onboarding.create_project.import_selected_repo')}
-              </Button>
-            </div>
-          )
-        }
-        title={
-          <span className="text-middle">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height="24"
-              src={`${getBaseUrl()}/images/alm/azure.svg`}
-            />
-            {translate('onboarding.create_project.azure.title')}
-          </span>
-        }
-      />
-
-      <AlmSettingsInstanceDropdown
-        almKey={AlmKeys.Azure}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onChangeConfig={props.onSelectedAlmInstanceChange}
-      />
-
-      {loading && <i className="spinner" />}
-
-      {showUrlError && (
-        <Alert variant="error">
-          {canAdmin ? (
-            <FormattedMessage
-              defaultMessage={translate('onboarding.create_project.azure.no_url.admin')}
-              id="onboarding.create_project.azure.no_url.admin"
-              values={{
-                alm: translate('onboarding.alm', AlmKeys.Azure),
-                url: (
-                  <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
-                    {translate('settings.page')}
-                  </Link>
-                ),
-              }}
-            />
-          ) : (
-            translate('onboarding.create_project.azure.no_url')
-          )}
-        </Alert>
-      )}
-
-      {showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />}
-
-      {!loading &&
-        selectedAlmInstance &&
-        selectedAlmInstance.url &&
-        (showPersonalAccessTokenForm ? (
-          <div>
-            <AzurePersonalAccessTokenForm
-              almSetting={selectedAlmInstance}
-              onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate}
-              submitting={submittingToken}
-              validationFailed={tokenValidationFailed}
-              firstConnection={firstConnection}
-            />
-          </div>
-        ) : (
-          <>
-            <div className="huge-spacer-bottom">
-              <SearchBox
-                onChange={props.onSearch}
-                placeholder={translate('onboarding.create_project.search_projects_repositories')}
-              />
-            </div>
-            <DeferredSpinner loading={Boolean(searching)}>
-              <AzureProjectsList
-                importing={importing}
-                loadingRepositories={loadingRepositories}
-                onOpenProject={props.onOpenProject}
-                onSelectRepository={props.onSelectRepository}
-                projects={projects}
-                repositories={repositories}
-                searchResults={searchResults}
-                searchQuery={searchQuery}
-                selectedRepository={selectedRepository}
-              />
-            </DeferredSpinner>
-          </>
-        ))}
-    </>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx
deleted file mode 100644 (file)
index 8fa25a3..0000000
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { uniqBy } from 'lodash';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import ListFooter from '../../../components/controls/ListFooter';
-import { Alert } from '../../../components/ui/Alert';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { queryToSearch } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { Dict } from '../../../types/types';
-import AzureProjectAccordion from './AzureProjectAccordion';
-import { CreateProjectModes } from './types';
-
-export interface AzureProjectsListProps {
-  importing: boolean;
-  loadingRepositories: Dict<boolean>;
-  onOpenProject: (key: string) => void;
-  onSelectRepository: (repository: AzureRepository) => void;
-  projects?: AzureProject[];
-  repositories: Dict<AzureRepository[]>;
-  searchResults?: AzureRepository[];
-  searchQuery?: string;
-  selectedRepository?: AzureRepository;
-}
-
-const PAGE_SIZE = 10;
-
-export default function AzureProjectsList(props: AzureProjectsListProps) {
-  const {
-    importing,
-    loadingRepositories,
-    projects = [],
-    repositories,
-    searchResults,
-    searchQuery,
-    selectedRepository,
-  } = props;
-
-  const [page, setPage] = React.useState(1);
-
-  if (searchResults && searchResults.length === 0) {
-    return (
-      <Alert className="spacer-top" variant="warning">
-        {translate('onboarding.create_project.azure.no_results')}
-      </Alert>
-    );
-  }
-
-  if (projects.length === 0) {
-    return (
-      <Alert className="spacer-top" variant="warning">
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.azure.no_projects')}
-          id="onboarding.create_project.azure.no_projects"
-          values={{
-            link: (
-              <Link
-                to={{
-                  pathname: '/projects/create',
-                  search: queryToSearch({ mode: CreateProjectModes.AzureDevOps, resetPat: 1 }),
-                }}
-              >
-                {translate('onboarding.create_project.update_your_token')}
-              </Link>
-            ),
-          }}
-        />
-      </Alert>
-    );
-  }
-
-  let filteredProjects: AzureProject[];
-  if (searchResults !== undefined) {
-    filteredProjects = uniqBy(
-      searchResults.map((r) => {
-        return (
-          projects.find((p) => p.name === r.projectName) || {
-            name: r.projectName,
-            description: translateWithParameters(
-              'onboarding.create_project.azure.search_results_for_project_X',
-              r.projectName
-            ),
-          }
-        );
-      }),
-      'name'
-    );
-  } else {
-    filteredProjects = projects;
-  }
-
-  const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE);
-
-  // Add a suffix to the key to force react to not reuse AzureProjectAccordions between
-  // search results and project exploration
-  const keySuffix = searchResults ? ' - result' : '';
-
-  return (
-    <div>
-      {displayedProjects.map((p, i) => (
-        <AzureProjectAccordion
-          key={`${p.name}${keySuffix}`}
-          importing={importing}
-          loading={Boolean(loadingRepositories[p.name])}
-          onOpen={props.onOpenProject}
-          onSelectRepository={props.onSelectRepository}
-          project={p}
-          repositories={
-            searchResults
-              ? searchResults.filter((s) => s.projectName === p.name)
-              : repositories[p.name]
-          }
-          selectedRepository={selectedRepository}
-          searchQuery={searchQuery}
-          startsOpen={searchResults !== undefined || i === 0}
-        />
-      ))}
-
-      <ListFooter
-        count={displayedProjects.length}
-        loadMore={() => setPage((p) => p + 1)}
-        total={filteredProjects.length}
-      />
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
new file mode 100644 (file)
index 0000000..dd4bf7a
--- /dev/null
@@ -0,0 +1,248 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import {
+  importBitbucketCloudRepository,
+  searchForBitbucketCloudRepositories,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';
+
+interface Props {
+  canAdmin: boolean;
+  almInstances: AlmSettingsInstance[];
+  loadingBindings: boolean;
+  onProjectCreate: (projectKey: string) => void;
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  importingSlug?: string;
+  isLastPage?: boolean;
+  loading: boolean;
+  loadingMore: boolean;
+  projectsPaging: Omit<Paging, 'total'>;
+  resetPat: boolean;
+  repositories: BitbucketCloudRepository[];
+  searching: boolean;
+  searchQuery: string;
+  selectedAlmInstance: AlmSettingsInstance;
+  showPersonalAccessTokenForm: boolean;
+}
+
+export const BITBUCKET_CLOUD_PROJECTS_PAGESIZE = 30;
+export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      // For now, we only handle a single instance. So we always use the first
+      // one from the list.
+      loading: false,
+      loadingMore: false,
+      resetPat: false,
+      projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+      repositories: [],
+      searching: false,
+      searchQuery: '',
+      selectedAlmInstance: props.almInstances[0],
+      showPersonalAccessTokenForm: true,
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
+    }
+  }
+
+  handlePersonalAccessTokenCreated = async () => {
+    this.setState({ showPersonalAccessTokenForm: false });
+    this.cleanUrl();
+    this.setState({ loading: true });
+    await this.fetchData();
+    this.setState({ loading: false });
+  };
+
+  cleanUrl = () => {
+    const { location, router } = this.props;
+    delete location.query.resetPat;
+    router.replace(location);
+  };
+
+  async fetchData(more = false) {
+    const {
+      selectedAlmInstance,
+      searchQuery,
+      projectsPaging: { pageIndex, pageSize },
+      showPersonalAccessTokenForm,
+    } = this.state;
+    if (selectedAlmInstance && !showPersonalAccessTokenForm) {
+      const { isLastPage, repositories } = await searchForBitbucketCloudRepositories(
+        selectedAlmInstance.key,
+        searchQuery,
+        pageSize,
+        pageIndex
+      ).catch(() => {
+        this.handleError();
+        return { isLastPage: undefined, repositories: undefined };
+      });
+      if (this.mounted && isLastPage !== undefined && repositories !== undefined) {
+        if (more) {
+          this.setState((state) => ({
+            isLastPage,
+            repositories: [...state.repositories, ...repositories],
+          }));
+        } else {
+          this.setState({ isLastPage, repositories });
+        }
+      }
+    }
+  }
+
+  handleError = () => {
+    if (this.mounted) {
+      this.setState({
+        projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+        repositories: [],
+        resetPat: true,
+        showPersonalAccessTokenForm: true,
+      });
+    }
+
+    return undefined;
+  };
+
+  handleSearch = (searchQuery: string) => {
+    this.setState(
+      {
+        searching: true,
+        projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+        searchQuery,
+      },
+      async () => {
+        await this.fetchData();
+        if (this.mounted) {
+          this.setState({ searching: false });
+        }
+      }
+    );
+  };
+
+  handleLoadMore = () => {
+    this.setState(
+      (state) => ({
+        loadingMore: true,
+        projectsPaging: {
+          pageIndex: state.projectsPaging.pageIndex + 1,
+          pageSize: state.projectsPaging.pageSize,
+        },
+      }),
+      async () => {
+        await this.fetchData(true);
+        if (this.mounted) {
+          this.setState({ loadingMore: false });
+        }
+      }
+    );
+  };
+
+  handleImport = async (repositorySlug: string) => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return;
+    }
+
+    this.setState({ importingSlug: repositorySlug });
+
+    const result = await importBitbucketCloudRepository(
+      selectedAlmInstance.key,
+      repositorySlug
+    ).catch(() => undefined);
+
+    if (this.mounted) {
+      this.setState({ importingSlug: undefined });
+
+      if (result) {
+        this.props.onProjectCreate(result.project.key);
+      }
+    }
+  };
+
+  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+    this.setState({
+      selectedAlmInstance: instance,
+      showPersonalAccessTokenForm: true,
+      resetPat: false,
+      searching: false,
+      searchQuery: '',
+      projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+    });
+  };
+
+  render() {
+    const { canAdmin, loadingBindings, location, almInstances } = this.props;
+    const {
+      importingSlug,
+      isLastPage = true,
+      selectedAlmInstance,
+      loading,
+      loadingMore,
+      repositories,
+      showPersonalAccessTokenForm,
+      resetPat,
+      searching,
+      searchQuery,
+    } = this.state;
+    return (
+      <BitbucketCloudProjectCreateRenderer
+        importingSlug={importingSlug}
+        isLastPage={isLastPage}
+        selectedAlmInstance={selectedAlmInstance}
+        almInstances={almInstances}
+        canAdmin={canAdmin}
+        loadingMore={loadingMore}
+        loading={loading || loadingBindings}
+        onImport={this.handleImport}
+        onLoadMore={this.handleLoadMore}
+        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+        onSearch={this.handleSearch}
+        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+        repositories={repositories}
+        searching={searching}
+        searchQuery={searchQuery}
+        resetPat={resetPat || Boolean(location.query.resetPat)}
+        showPersonalAccessTokenForm={
+          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+        }
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreateRender.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreateRender.tsx
new file mode 100644 (file)
index 0000000..28356e4
--- /dev/null
@@ -0,0 +1,121 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import BitbucketCloudSearchForm from './BitbucketCloudSearchForm';
+
+export interface BitbucketCloudProjectCreateRendererProps {
+  importingSlug?: string;
+  isLastPage: boolean;
+  canAdmin?: boolean;
+  loading: boolean;
+  loadingMore: boolean;
+  onImport: (repositorySlug: string) => void;
+  onLoadMore: () => void;
+  onPersonalAccessTokenCreated: () => void;
+  onSearch: (searchQuery: string) => void;
+  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+  repositories?: BitbucketCloudRepository[];
+  resetPat: boolean;
+  searching: boolean;
+  searchQuery: string;
+  showPersonalAccessTokenForm: boolean;
+  almInstances: AlmSettingsInstance[];
+  selectedAlmInstance?: AlmSettingsInstance;
+}
+
+export default function BitbucketCloudProjectCreateRenderer(
+  props: BitbucketCloudProjectCreateRendererProps
+) {
+  const {
+    almInstances,
+    importingSlug,
+    isLastPage,
+    selectedAlmInstance,
+    canAdmin,
+    loading,
+    loadingMore,
+    repositories,
+    resetPat,
+    searching,
+    searchQuery,
+    showPersonalAccessTokenForm,
+  } = props;
+
+  return (
+    <>
+      <CreateProjectPageHeader
+        title={
+          <span className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="24"
+              src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
+            />
+            {translate('onboarding.create_project.bitbucketcloud.title')}
+          </span>
+        }
+      />
+
+      <AlmSettingsInstanceDropdown
+        almKey={AlmKeys.BitbucketCloud}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onChangeConfig={props.onSelectedAlmInstanceChange}
+      />
+
+      {loading && <i className="spinner" />}
+
+      {!loading && !selectedAlmInstance && (
+        <WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} />
+      )}
+
+      {!loading &&
+        selectedAlmInstance &&
+        (showPersonalAccessTokenForm ? (
+          <PersonalAccessTokenForm
+            almSetting={selectedAlmInstance}
+            resetPat={resetPat}
+            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+          />
+        ) : (
+          <BitbucketCloudSearchForm
+            importingSlug={importingSlug}
+            isLastPage={isLastPage}
+            loadingMore={loadingMore}
+            searchQuery={searchQuery}
+            searching={searching}
+            onImport={props.onImport}
+            onSearch={props.onSearch}
+            onLoadMore={props.onLoadMore}
+            repositories={repositories}
+          />
+        ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx
new file mode 100644 (file)
index 0000000..d52b70a
--- /dev/null
@@ -0,0 +1,194 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Tooltip from '../../../../components/controls/Tooltip';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { formatMeasure } from '../../../../helpers/measures';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { ComponentQualifier } from '../../../../types/component';
+import { MetricType } from '../../../../types/metrics';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+
+export interface BitbucketCloudSearchFormProps {
+  importingSlug?: string;
+  isLastPage: boolean;
+  loadingMore: boolean;
+  onImport: (repositorySlug: string) => void;
+  onLoadMore: () => void;
+  onSearch: (searchQuery: string) => void;
+  repositories?: BitbucketCloudRepository[];
+  searching: boolean;
+  searchQuery: string;
+}
+
+function getRepositoryUrl(workspace: string, slug: string) {
+  return `https://bitbucket.org/${workspace}/${slug}`;
+}
+
+export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchFormProps) {
+  const {
+    importingSlug,
+    isLastPage,
+    loadingMore,
+    repositories = [],
+    searching,
+    searchQuery,
+  } = props;
+
+  if (repositories.length === 0 && searchQuery.length === 0 && !searching) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.bitbucketcloud.no_projects')}
+          id="onboarding.create_project.bitbucketcloud.no_projects"
+          values={{
+            link: (
+              <Link
+                to={{
+                  pathname: '/projects/create',
+                  search: queryToSearch({ mode: CreateProjectModes.BitbucketCloud, resetPat: 1 }),
+                }}
+              >
+                {translate('onboarding.create_project.update_your_token')}
+              </Link>
+            ),
+          }}
+        />
+      </Alert>
+    );
+  }
+
+  return (
+    <>
+      <InstanceNewCodeDefinitionComplianceWarning />
+      <div className="boxed-group big-padded create-project-import">
+        <SearchBox
+          className="spacer"
+          loading={searching}
+          minLength={3}
+          onChange={props.onSearch}
+          placeholder={translate('onboarding.create_project.search_prompt')}
+        />
+
+        <hr />
+
+        {repositories.length === 0 ? (
+          <div className="padded">{translate('no_results')}</div>
+        ) : (
+          <table className="data zebra zebra-hover">
+            <tbody>
+              {repositories.map((repository) => (
+                <tr key={repository.uuid}>
+                  <td>
+                    <Tooltip overlay={repository.slug}>
+                      <strong className="project-name display-inline-block text-ellipsis">
+                        {repository.sqProjectKey ? (
+                          <Link to={getProjectUrl(repository.sqProjectKey)}>
+                            <QualifierIcon
+                              className="spacer-right"
+                              qualifier={ComponentQualifier.Project}
+                            />
+                            {repository.name}
+                          </Link>
+                        ) : (
+                          repository.name
+                        )}
+                      </strong>
+                    </Tooltip>
+                    <br />
+                    <Tooltip overlay={repository.projectKey}>
+                      <span className="text-muted project-path display-inline-block text-ellipsis">
+                        {repository.projectKey}
+                      </span>
+                    </Tooltip>
+                  </td>
+                  <td>
+                    <Link
+                      className="display-inline-flex-center big-spacer-right"
+                      to={getRepositoryUrl(repository.workspace, repository.slug)}
+                      target="_blank"
+                    >
+                      {translate('onboarding.create_project.bitbucketcloud.link')}
+                    </Link>
+                  </td>
+                  {repository.sqProjectKey ? (
+                    <td>
+                      <span className="display-flex-center display-flex-justify-end already-set-up">
+                        <CheckIcon className="little-spacer-right" size={12} />
+                        {translate('onboarding.create_project.repository_imported')}
+                      </span>
+                    </td>
+                  ) : (
+                    <td className="text-right">
+                      <Button
+                        disabled={Boolean(importingSlug)}
+                        onClick={() => {
+                          props.onImport(repository.slug);
+                        }}
+                      >
+                        {translate('onboarding.create_project.set_up')}
+                        <DeferredSpinner
+                          className="spacer-left"
+                          loading={importingSlug === repository.slug}
+                        />
+                      </Button>
+                    </td>
+                  )}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+        <footer className="spacer-top note text-center">
+          {isLastPage &&
+            translateWithParameters(
+              'x_of_y_shown',
+              formatMeasure(repositories.length, MetricType.Integer, null),
+              formatMeasure(repositories.length, MetricType.Integer, null)
+            )}
+          {!isLastPage && (
+            <Button
+              className="spacer-left"
+              disabled={loadingMore}
+              data-test="show-more"
+              onClick={props.onLoadMore}
+            >
+              {translate('show_more')}
+            </Button>
+          )}
+          <DeferredSpinner
+            className="text-bottom spacer-left position-absolute"
+            loading={loadingMore}
+          />
+        </footer>
+      </div>
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx
deleted file mode 100644 (file)
index 9f7fe3b..0000000
+++ /dev/null
@@ -1,248 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import {
-  importBitbucketCloudRepository,
-  searchForBitbucketCloudRepositories,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';
-
-interface Props {
-  canAdmin: boolean;
-  almInstances: AlmSettingsInstance[];
-  loadingBindings: boolean;
-  onProjectCreate: (projectKey: string) => void;
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  importingSlug?: string;
-  isLastPage?: boolean;
-  loading: boolean;
-  loadingMore: boolean;
-  projectsPaging: Omit<Paging, 'total'>;
-  resetPat: boolean;
-  repositories: BitbucketCloudRepository[];
-  searching: boolean;
-  searchQuery: string;
-  selectedAlmInstance: AlmSettingsInstance;
-  showPersonalAccessTokenForm: boolean;
-}
-
-export const BITBUCKET_CLOUD_PROJECTS_PAGESIZE = 30;
-export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      // For now, we only handle a single instance. So we always use the first
-      // one from the list.
-      loading: false,
-      loadingMore: false,
-      resetPat: false,
-      projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
-      repositories: [],
-      searching: false,
-      searchQuery: '',
-      selectedAlmInstance: props.almInstances[0],
-      showPersonalAccessTokenForm: true,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
-    }
-  }
-
-  handlePersonalAccessTokenCreated = async () => {
-    this.setState({ showPersonalAccessTokenForm: false });
-    this.cleanUrl();
-    this.setState({ loading: true });
-    await this.fetchData();
-    this.setState({ loading: false });
-  };
-
-  cleanUrl = () => {
-    const { location, router } = this.props;
-    delete location.query.resetPat;
-    router.replace(location);
-  };
-
-  async fetchData(more = false) {
-    const {
-      selectedAlmInstance,
-      searchQuery,
-      projectsPaging: { pageIndex, pageSize },
-      showPersonalAccessTokenForm,
-    } = this.state;
-    if (selectedAlmInstance && !showPersonalAccessTokenForm) {
-      const { isLastPage, repositories } = await searchForBitbucketCloudRepositories(
-        selectedAlmInstance.key,
-        searchQuery,
-        pageSize,
-        pageIndex
-      ).catch(() => {
-        this.handleError();
-        return { isLastPage: undefined, repositories: undefined };
-      });
-      if (this.mounted && isLastPage !== undefined && repositories !== undefined) {
-        if (more) {
-          this.setState((state) => ({
-            isLastPage,
-            repositories: [...state.repositories, ...repositories],
-          }));
-        } else {
-          this.setState({ isLastPage, repositories });
-        }
-      }
-    }
-  }
-
-  handleError = () => {
-    if (this.mounted) {
-      this.setState({
-        projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
-        repositories: [],
-        resetPat: true,
-        showPersonalAccessTokenForm: true,
-      });
-    }
-
-    return undefined;
-  };
-
-  handleSearch = (searchQuery: string) => {
-    this.setState(
-      {
-        searching: true,
-        projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
-        searchQuery,
-      },
-      async () => {
-        await this.fetchData();
-        if (this.mounted) {
-          this.setState({ searching: false });
-        }
-      }
-    );
-  };
-
-  handleLoadMore = () => {
-    this.setState(
-      (state) => ({
-        loadingMore: true,
-        projectsPaging: {
-          pageIndex: state.projectsPaging.pageIndex + 1,
-          pageSize: state.projectsPaging.pageSize,
-        },
-      }),
-      async () => {
-        await this.fetchData(true);
-        if (this.mounted) {
-          this.setState({ loadingMore: false });
-        }
-      }
-    );
-  };
-
-  handleImport = async (repositorySlug: string) => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return;
-    }
-
-    this.setState({ importingSlug: repositorySlug });
-
-    const result = await importBitbucketCloudRepository(
-      selectedAlmInstance.key,
-      repositorySlug
-    ).catch(() => undefined);
-
-    if (this.mounted) {
-      this.setState({ importingSlug: undefined });
-
-      if (result) {
-        this.props.onProjectCreate(result.project.key);
-      }
-    }
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState({
-      selectedAlmInstance: instance,
-      showPersonalAccessTokenForm: true,
-      resetPat: false,
-      searching: false,
-      searchQuery: '',
-      projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
-    });
-  };
-
-  render() {
-    const { canAdmin, loadingBindings, location, almInstances } = this.props;
-    const {
-      importingSlug,
-      isLastPage = true,
-      selectedAlmInstance,
-      loading,
-      loadingMore,
-      repositories,
-      showPersonalAccessTokenForm,
-      resetPat,
-      searching,
-      searchQuery,
-    } = this.state;
-    return (
-      <BitbucketCloudProjectCreateRenderer
-        importingSlug={importingSlug}
-        isLastPage={isLastPage}
-        selectedAlmInstance={selectedAlmInstance}
-        almInstances={almInstances}
-        canAdmin={canAdmin}
-        loadingMore={loadingMore}
-        loading={loading || loadingBindings}
-        onImport={this.handleImport}
-        onLoadMore={this.handleLoadMore}
-        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
-        onSearch={this.handleSearch}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-        repositories={repositories}
-        searching={searching}
-        searchQuery={searchQuery}
-        resetPat={resetPat || Boolean(location.query.resetPat)}
-        showPersonalAccessTokenForm={
-          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
-        }
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx
deleted file mode 100644 (file)
index fcd4d48..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import BitbucketCloudSearchForm from './BitbucketCloudSearchForm';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface BitbucketCloudProjectCreateRendererProps {
-  importingSlug?: string;
-  isLastPage: boolean;
-  canAdmin?: boolean;
-  loading: boolean;
-  loadingMore: boolean;
-  onImport: (repositorySlug: string) => void;
-  onLoadMore: () => void;
-  onPersonalAccessTokenCreated: () => void;
-  onSearch: (searchQuery: string) => void;
-  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-  repositories?: BitbucketCloudRepository[];
-  resetPat: boolean;
-  searching: boolean;
-  searchQuery: string;
-  showPersonalAccessTokenForm: boolean;
-  almInstances: AlmSettingsInstance[];
-  selectedAlmInstance?: AlmSettingsInstance;
-}
-
-export default function BitbucketCloudProjectCreateRenderer(
-  props: BitbucketCloudProjectCreateRendererProps
-) {
-  const {
-    almInstances,
-    importingSlug,
-    isLastPage,
-    selectedAlmInstance,
-    canAdmin,
-    loading,
-    loadingMore,
-    repositories,
-    resetPat,
-    searching,
-    searchQuery,
-    showPersonalAccessTokenForm,
-  } = props;
-
-  return (
-    <>
-      <CreateProjectPageHeader
-        title={
-          <span className="text-middle">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height="24"
-              src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
-            />
-            {translate('onboarding.create_project.bitbucketcloud.title')}
-          </span>
-        }
-      />
-
-      <AlmSettingsInstanceDropdown
-        almKey={AlmKeys.BitbucketCloud}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onChangeConfig={props.onSelectedAlmInstanceChange}
-      />
-
-      {loading && <i className="spinner" />}
-
-      {!loading && !selectedAlmInstance && (
-        <WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} />
-      )}
-
-      {!loading &&
-        selectedAlmInstance &&
-        (showPersonalAccessTokenForm ? (
-          <PersonalAccessTokenForm
-            almSetting={selectedAlmInstance}
-            resetPat={resetPat}
-            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
-          />
-        ) : (
-          <BitbucketCloudSearchForm
-            importingSlug={importingSlug}
-            isLastPage={isLastPage}
-            loadingMore={loadingMore}
-            searchQuery={searchQuery}
-            searching={searching}
-            onImport={props.onImport}
-            onSearch={props.onSearch}
-            onLoadMore={props.onLoadMore}
-            repositories={repositories}
-          />
-        ))}
-    </>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudSearchForm.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudSearchForm.tsx
deleted file mode 100644 (file)
index 0e420e9..0000000
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import SearchBox from '../../../components/controls/SearchBox';
-import Tooltip from '../../../components/controls/Tooltip';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { formatMeasure } from '../../../helpers/measures';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { ComponentQualifier } from '../../../types/component';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketCloudSearchFormProps {
-  importingSlug?: string;
-  isLastPage: boolean;
-  loadingMore: boolean;
-  onImport: (repositorySlug: string) => void;
-  onLoadMore: () => void;
-  onSearch: (searchQuery: string) => void;
-  repositories?: BitbucketCloudRepository[];
-  searching: boolean;
-  searchQuery: string;
-}
-
-function getRepositoryUrl(workspace: string, slug: string) {
-  return `https://bitbucket.org/${workspace}/${slug}`;
-}
-
-export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchFormProps) {
-  const {
-    importingSlug,
-    isLastPage,
-    loadingMore,
-    repositories = [],
-    searching,
-    searchQuery,
-  } = props;
-
-  if (repositories.length === 0 && searchQuery.length === 0 && !searching) {
-    return (
-      <Alert className="spacer-top" variant="warning">
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.bitbucketcloud.no_projects')}
-          id="onboarding.create_project.bitbucketcloud.no_projects"
-          values={{
-            link: (
-              <Link
-                to={{
-                  pathname: '/projects/create',
-                  search: queryToSearch({ mode: CreateProjectModes.BitbucketCloud, resetPat: 1 }),
-                }}
-              >
-                {translate('onboarding.create_project.update_your_token')}
-              </Link>
-            ),
-          }}
-        />
-      </Alert>
-    );
-  }
-
-  return (
-    <div className="boxed-group big-padded create-project-import">
-      <SearchBox
-        className="spacer"
-        loading={searching}
-        minLength={3}
-        onChange={props.onSearch}
-        placeholder={translate('onboarding.create_project.search_prompt')}
-      />
-
-      <hr />
-
-      {repositories.length === 0 ? (
-        <div className="padded">{translate('no_results')}</div>
-      ) : (
-        <table className="data zebra zebra-hover">
-          <tbody>
-            {repositories.map((repository) => (
-              <tr key={repository.uuid}>
-                <td>
-                  <Tooltip overlay={repository.slug}>
-                    <strong className="project-name display-inline-block text-ellipsis">
-                      {repository.sqProjectKey ? (
-                        <Link to={getProjectUrl(repository.sqProjectKey)}>
-                          <QualifierIcon
-                            className="spacer-right"
-                            qualifier={ComponentQualifier.Project}
-                          />
-                          {repository.name}
-                        </Link>
-                      ) : (
-                        repository.name
-                      )}
-                    </strong>
-                  </Tooltip>
-                  <br />
-                  <Tooltip overlay={repository.projectKey}>
-                    <span className="text-muted project-path display-inline-block text-ellipsis">
-                      {repository.projectKey}
-                    </span>
-                  </Tooltip>
-                </td>
-                <td>
-                  <Link
-                    className="display-inline-flex-center big-spacer-right"
-                    to={getRepositoryUrl(repository.workspace, repository.slug)}
-                    target="_blank"
-                  >
-                    {translate('onboarding.create_project.bitbucketcloud.link')}
-                  </Link>
-                </td>
-                {repository.sqProjectKey ? (
-                  <td>
-                    <span className="display-flex-center display-flex-justify-end already-set-up">
-                      <CheckIcon className="little-spacer-right" size={12} />
-                      {translate('onboarding.create_project.repository_imported')}
-                    </span>
-                  </td>
-                ) : (
-                  <td className="text-right">
-                    <Button
-                      disabled={Boolean(importingSlug)}
-                      onClick={() => {
-                        props.onImport(repository.slug);
-                      }}
-                    >
-                      {translate('onboarding.create_project.set_up')}
-                      {importingSlug === repository.slug && (
-                        <DeferredSpinner className="spacer-left" />
-                      )}
-                    </Button>
-                  </td>
-                )}
-              </tr>
-            ))}
-          </tbody>
-        </table>
-      )}
-      <footer className="spacer-top note text-center">
-        {isLastPage &&
-          translateWithParameters(
-            'x_of_y_shown',
-            formatMeasure(repositories.length, 'INT', null),
-            formatMeasure(repositories.length, 'INT', null)
-          )}
-        {!isLastPage && (
-          <Button
-            className="spacer-left"
-            disabled={loadingMore}
-            data-test="show-more"
-            onClick={props.onLoadMore}
-          >
-            {translate('show_more')}
-          </Button>
-        )}
-        {loadingMore && <DeferredSpinner className="text-bottom spacer-left position-absolute" />}
-      </footer>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketImportRepositoryForm.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketImportRepositoryForm.tsx
deleted file mode 100644 (file)
index 6e00c7e..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import SearchBox from '../../../components/controls/SearchBox';
-import { Alert } from '../../../components/ui/Alert';
-import { translate } from '../../../helpers/l10n';
-import { queryToSearch } from '../../../helpers/urls';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../types/alm-integration';
-import BitbucketRepositories from './BitbucketRepositories';
-import BitbucketSearchResults from './BitbucketSearchResults';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketImportRepositoryFormProps {
-  disableRepositories: boolean;
-  onSearch: (query: string) => void;
-  onSelectRepository: (repo: BitbucketRepository) => void;
-  projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
-  searching: boolean;
-  searchResults?: BitbucketRepository[];
-  selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketImportRepositoryForm(props: BitbucketImportRepositoryFormProps) {
-  const {
-    disableRepositories,
-    projects = [],
-    projectRepositories = {},
-    searchResults,
-    searching,
-    selectedRepository,
-  } = props;
-
-  if (projects.length === 0) {
-    return (
-      <Alert className="spacer-top" variant="warning">
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.no_bbs_projects')}
-          id="onboarding.create_project.no_bbs_projects"
-          values={{
-            link: (
-              <Link
-                to={{
-                  pathname: '/projects/create',
-                  search: queryToSearch({ mode: CreateProjectModes.BitbucketServer, resetPat: 1 }),
-                }}
-              >
-                {translate('onboarding.create_project.update_your_token')}
-              </Link>
-            ),
-          }}
-        />
-      </Alert>
-    );
-  }
-
-  return (
-    <div className="create-project-import-bbs">
-      <SearchBox
-        onChange={props.onSearch}
-        placeholder={translate('onboarding.create_project.search_repositories_by_name')}
-      />
-
-      {searching || searchResults ? (
-        <BitbucketSearchResults
-          disableRepositories={disableRepositories}
-          onSelectRepository={props.onSelectRepository}
-          projects={projects}
-          searchResults={searchResults}
-          searching={searching}
-          selectedRepository={selectedRepository}
-        />
-      ) : (
-        <BitbucketRepositories
-          disableRepositories={disableRepositories}
-          onSelectRepository={props.onSelectRepository}
-          projectRepositories={projectRepositories}
-          projects={projects}
-          selectedRepository={selectedRepository}
-        />
-      )}
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectAccordion.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectAccordion.tsx
deleted file mode 100644 (file)
index 4eaa994..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
-import Radio from '../../../components/controls/Radio';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import { Alert } from '../../../components/ui/Alert';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { BitbucketProject, BitbucketRepository } from '../../../types/alm-integration';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketProjectAccordionProps {
-  disableRepositories: boolean;
-  onClick?: () => void;
-  onSelectRepository: (repo: BitbucketRepository) => void;
-  open: boolean;
-  project?: BitbucketProject;
-  repositories: BitbucketRepository[];
-  selectedRepository?: BitbucketRepository;
-  showingAllRepositories: boolean;
-}
-
-export default function BitbucketProjectAccordion(props: BitbucketProjectAccordionProps) {
-  const {
-    disableRepositories,
-    open,
-    project,
-    repositories,
-    selectedRepository,
-    showingAllRepositories,
-  } = props;
-
-  const repositoryCount = repositories.length;
-
-  const title = project?.name ?? translate('search_results');
-
-  return (
-    <BoxedGroupAccordion
-      className={classNames('big-spacer-bottom', {
-        open,
-        'not-clickable': !props.onClick,
-        'no-hover': !props.onClick,
-      })}
-      onClick={
-        props.onClick
-          ? props.onClick
-          : () => {
-              /* noop */
-            }
-      }
-      open={open}
-      title={<h3>{title}</h3>}
-    >
-      {open && (
-        <>
-          <div className="display-flex-wrap">
-            {repositoryCount === 0 && (
-              <Alert variant="warning">
-                <FormattedMessage
-                  defaultMessage={translate('onboarding.create_project.no_bbs_repos')}
-                  id="onboarding.create_project.no_bbs_repos"
-                  values={{
-                    link: (
-                      <Link
-                        to={{
-                          pathname: '/projects/create',
-                          search: queryToSearch({
-                            mode: CreateProjectModes.BitbucketServer,
-                            resetPat: 1,
-                          }),
-                        }}
-                      >
-                        {translate('onboarding.create_project.update_your_token')}
-                      </Link>
-                    ),
-                  }}
-                />
-              </Alert>
-            )}
-
-            {repositories.map((repo) =>
-              repo.sqProjectKey ? (
-                <div
-                  className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo"
-                  key={repo.id}
-                >
-                  <CheckIcon className="spacer-right" fill={colors.green} size={14} />
-                  <div className="overflow-hidden">
-                    <div className="little-spacer-bottom">
-                      <strong title={repo.name}>
-                        <Link to={getProjectUrl(repo.sqProjectKey)}>{repo.name}</Link>
-                      </strong>
-                    </div>
-                    <em>{translate('onboarding.create_project.repository_imported')}</em>
-                  </div>
-                </div>
-              ) : (
-                <Radio
-                  checked={selectedRepository?.id === repo.id}
-                  className={classNames(
-                    'display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden',
-                    {
-                      disabled: disableRepositories,
-                    }
-                  )}
-                  key={repo.id}
-                  onCheck={() => props.onSelectRepository(repo)}
-                  value={String(repo.id)}
-                >
-                  <strong title={repo.name}>{repo.name}</strong>
-                </Radio>
-              )
-            )}
-          </div>
-
-          {!showingAllRepositories && repositoryCount > 0 && (
-            <Alert variant="warning">
-              {translateWithParameters(
-                'onboarding.create_project.only_showing_X_first_repos',
-                repositoryCount
-              )}
-            </Alert>
-          )}
-        </>
-      )}
-    </BoxedGroupAccordion>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx
deleted file mode 100644 (file)
index 7719f31..0000000
+++ /dev/null
@@ -1,288 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import {
-  getBitbucketServerProjects,
-  getBitbucketServerRepositories,
-  importBitbucketServerProject,
-  searchForBitbucketServerRepositories,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
-import { DEFAULT_BBS_PAGE_SIZE } from './constants';
-
-interface Props {
-  canAdmin: boolean;
-  almInstances: AlmSettingsInstance[];
-  loadingBindings: boolean;
-  onProjectCreate: (projectKey: string) => void;
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  selectedAlmInstance?: AlmSettingsInstance;
-  importing: boolean;
-  loading: boolean;
-  projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
-  searching: boolean;
-  searchResults?: BitbucketRepository[];
-  selectedRepository?: BitbucketRepository;
-  showPersonalAccessTokenForm: boolean;
-}
-
-export default class BitbucketProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      // For now, we only handle a single instance. So we always use the first
-      // one from the list.
-      selectedAlmInstance: props.almInstances[0],
-      importing: false,
-      loading: false,
-      searching: false,
-      showPersonalAccessTokenForm: true,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () =>
-        this.fetchInitialData()
-      );
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchInitialData = async () => {
-    const { showPersonalAccessTokenForm } = this.state;
-
-    if (!showPersonalAccessTokenForm) {
-      this.setState({ loading: true });
-      const projects = await this.fetchBitbucketProjects().catch(() => undefined);
-
-      let projectRepositories;
-      if (projects && projects.length > 0) {
-        projectRepositories = await this.fetchBitbucketRepositories(projects).catch(
-          () => undefined
-        );
-      }
-
-      if (this.mounted) {
-        this.setState({
-          projects,
-          projectRepositories,
-          loading: false,
-        });
-      }
-    }
-  };
-
-  fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
-
-    return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
-  };
-
-  fetchBitbucketRepositories = (
-    projects: BitbucketProject[]
-  ): Promise<BitbucketProjectRepositories | undefined> => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
-
-    return Promise.all(
-      projects.map((p) => {
-        return getBitbucketServerRepositories(selectedAlmInstance.key, p.name).then(
-          ({ isLastPage, repositories }) => {
-            // Because the WS uses the project name rather than its key to find
-            // repositories, we can match more repositories than we expect. For
-            // example, p.name = "A1" would find repositories for projects "A1",
-            // "A10", "A11", etc. This is a limitation of BBS. To make sure we
-            // don't display incorrect information, filter on the project key.
-            const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);
-
-            // And because of the above, the "isLastPage" cannot be relied upon
-            // either. This one is impossible to get 100% for now. We can only
-            // make some assumptions: by default, the page size for BBS is 25
-            // (this is not part of the payload, so we don't know the actual
-            // number; but changing this implies changing some advanced config,
-            // so it's not likely). If the filtered repos is larger than this
-            // number AND isLastPage is false, we'll keep it at false.
-            // Otherwise, we assume it's true.
-            const realIsLastPage =
-              isLastPage || filteredRepositories.length < DEFAULT_BBS_PAGE_SIZE;
-
-            return {
-              repositories: filteredRepositories,
-              isLastPage: realIsLastPage,
-              projectKey: p.key,
-            };
-          }
-        );
-      })
-    ).then((results) => {
-      return results.reduce(
-        (acc: BitbucketProjectRepositories, { isLastPage, projectKey, repositories }) => {
-          return { ...acc, [projectKey]: { allShown: isLastPage, repositories } };
-        },
-        {}
-      );
-    });
-  };
-
-  cleanUrl = () => {
-    const { location, router } = this.props;
-    delete location.query.resetPat;
-    router.replace(location);
-  };
-
-  handlePersonalAccessTokenCreated = async () => {
-    this.setState({ showPersonalAccessTokenForm: false });
-    this.cleanUrl();
-    await this.fetchInitialData();
-  };
-
-  handleImportRepository = () => {
-    const { selectedAlmInstance, selectedRepository } = this.state;
-
-    if (!selectedAlmInstance || !selectedRepository) {
-      return;
-    }
-
-    this.setState({ importing: true });
-    importBitbucketServerProject(
-      selectedAlmInstance.key,
-      selectedRepository.projectKey,
-      selectedRepository.slug
-    )
-      .then(({ project: { key } }) => {
-        if (this.mounted) {
-          this.setState({ importing: false });
-          this.props.onProjectCreate(key);
-        }
-      })
-      .catch(() => {
-        if (this.mounted) {
-          this.setState({ importing: false });
-        }
-      });
-  };
-
-  handleSearch = (query: string) => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return;
-    }
-
-    if (!query) {
-      this.setState({ searching: false, searchResults: undefined, selectedRepository: undefined });
-      return;
-    }
-
-    this.setState({ searching: true, selectedRepository: undefined });
-    searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
-      .then(({ repositories }) => {
-        if (this.mounted) {
-          this.setState({ searching: false, searchResults: repositories });
-        }
-      })
-      .catch(() => {
-        if (this.mounted) {
-          this.setState({ searching: false });
-        }
-      });
-  };
-
-  handleSelectRepository = (selectedRepository: BitbucketRepository) => {
-    this.setState({ selectedRepository });
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState({
-      selectedAlmInstance: instance,
-      showPersonalAccessTokenForm: true,
-      searching: false,
-      searchResults: undefined,
-    });
-  };
-
-  render() {
-    const { canAdmin, loadingBindings, location, almInstances } = this.props;
-    const {
-      selectedAlmInstance,
-      importing,
-      loading,
-      projectRepositories,
-      projects,
-      searching,
-      searchResults,
-      selectedRepository,
-      showPersonalAccessTokenForm,
-    } = this.state;
-
-    return (
-      <BitbucketCreateProjectRenderer
-        selectedAlmInstance={selectedAlmInstance}
-        almInstances={almInstances}
-        canAdmin={canAdmin}
-        importing={importing}
-        loading={loading || loadingBindings}
-        onImportRepository={this.handleImportRepository}
-        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
-        onSearch={this.handleSearch}
-        onSelectRepository={this.handleSelectRepository}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-        projectRepositories={projectRepositories}
-        projects={projects}
-        resetPat={Boolean(location.query.resetPat)}
-        searchResults={searchResults}
-        searching={searching}
-        selectedRepository={selectedRepository}
-        showPersonalAccessTokenForm={
-          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
-        }
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx
deleted file mode 100644 (file)
index 2acbe01..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { Button } from '../../../components/controls/buttons';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface BitbucketProjectCreateRendererProps {
-  selectedAlmInstance?: AlmSettingsInstance;
-  almInstances: AlmSettingsInstance[];
-  canAdmin?: boolean;
-  importing: boolean;
-  loading: boolean;
-  onImportRepository: () => void;
-  onSearch: (query: string) => void;
-  onSelectRepository: (repo: BitbucketRepository) => void;
-  onPersonalAccessTokenCreated: () => void;
-  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-  projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
-  resetPat: boolean;
-  searching: boolean;
-  searchResults?: BitbucketRepository[];
-  selectedRepository?: BitbucketRepository;
-  showPersonalAccessTokenForm?: boolean;
-}
-
-export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
-  const {
-    almInstances,
-    selectedAlmInstance,
-    canAdmin,
-    importing,
-    loading,
-    projects,
-    projectRepositories,
-    selectedRepository,
-    searching,
-    searchResults,
-    showPersonalAccessTokenForm,
-    resetPat,
-  } = props;
-
-  return (
-    <>
-      <CreateProjectPageHeader
-        additionalActions={
-          !showPersonalAccessTokenForm && (
-            <div className="display-flex-center pull-right">
-              <DeferredSpinner className="spacer-right" loading={importing} />
-              <Button
-                className="button-large button-primary"
-                disabled={!selectedRepository || importing}
-                onClick={props.onImportRepository}
-              >
-                {translate('onboarding.create_project.import_selected_repo')}
-              </Button>
-            </div>
-          )
-        }
-        title={
-          <span className="text-middle">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height="24"
-              src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
-            />
-            {translate('onboarding.create_project.from_bbs')}
-          </span>
-        }
-      />
-
-      <AlmSettingsInstanceDropdown
-        almKey={AlmKeys.BitbucketServer}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onChangeConfig={props.onSelectedAlmInstanceChange}
-      />
-
-      {loading && <i className="spinner" />}
-
-      {!loading && !selectedAlmInstance && (
-        <WrongBindingCountAlert alm={AlmKeys.BitbucketServer} canAdmin={!!canAdmin} />
-      )}
-
-      {!loading &&
-        selectedAlmInstance &&
-        (showPersonalAccessTokenForm ? (
-          <PersonalAccessTokenForm
-            almSetting={selectedAlmInstance}
-            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
-            resetPat={resetPat}
-          />
-        ) : (
-          <BitbucketImportRepositoryForm
-            disableRepositories={importing}
-            onSearch={props.onSearch}
-            onSelectRepository={props.onSelectRepository}
-            projectRepositories={projectRepositories}
-            projects={projects}
-            searchResults={searchResults}
-            searching={searching}
-            selectedRepository={selectedRepository}
-          />
-        ))}
-    </>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketRepositories.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketRepositories.tsx
deleted file mode 100644 (file)
index 4630503..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { uniq, without } from 'lodash';
-import * as React from 'react';
-import { ButtonLink } from '../../../components/controls/buttons';
-import { translate } from '../../../helpers/l10n';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../types/alm-integration';
-import BitbucketProjectAccordion from './BitbucketProjectAccordion';
-
-export interface BitbucketRepositoriesProps {
-  disableRepositories: boolean;
-  onSelectRepository: (repo: BitbucketRepository) => void;
-  projects: BitbucketProject[];
-  projectRepositories: BitbucketProjectRepositories;
-  selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
-  const { disableRepositories, projects, projectRepositories, selectedRepository } = props;
-
-  const [openProjectKeys, setOpenProjectKeys] = React.useState(
-    projects.length > 0 ? [projects[0].key] : []
-  );
-
-  const allAreExpanded = projects.length <= openProjectKeys.length;
-
-  const handleClick = (isOpen: boolean, projectKey: string) => {
-    setOpenProjectKeys(
-      isOpen ? without(openProjectKeys, projectKey) : uniq([...openProjectKeys, projectKey])
-    );
-  };
-
-  return (
-    <>
-      <div className="overflow-hidden spacer-bottom">
-        <ButtonLink
-          className="pull-right"
-          onClick={() => setOpenProjectKeys(allAreExpanded ? [] : projects.map((p) => p.key))}
-        >
-          {allAreExpanded ? translate('collapse_all') : translate('expand_all')}
-        </ButtonLink>
-      </div>
-
-      {projects.map((project) => {
-        const isOpen = openProjectKeys.includes(project.key);
-        const { allShown, repositories = [] } = projectRepositories[project.key] || {};
-
-        return (
-          <BitbucketProjectAccordion
-            disableRepositories={disableRepositories}
-            key={project.key}
-            onClick={() => handleClick(isOpen, project.key)}
-            onSelectRepository={props.onSelectRepository}
-            open={isOpen}
-            project={project}
-            repositories={repositories}
-            selectedRepository={selectedRepository}
-            showingAllRepositories={allShown}
-          />
-        );
-      })}
-    </>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketSearchResults.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketSearchResults.tsx
deleted file mode 100644 (file)
index 89870c0..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { BitbucketProject, BitbucketRepository } from '../../../types/alm-integration';
-import BitbucketProjectAccordion from './BitbucketProjectAccordion';
-
-export interface BitbucketSearchResultsProps {
-  disableRepositories: boolean;
-  onSelectRepository: (repo: BitbucketRepository) => void;
-  projects: BitbucketProject[];
-  searching: boolean;
-  searchResults?: BitbucketRepository[];
-  selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketSearchResults(props: BitbucketSearchResultsProps) {
-  const {
-    disableRepositories,
-    projects,
-    searching,
-    searchResults = [],
-    selectedRepository,
-  } = props;
-
-  if (searchResults.length === 0 && !searching) {
-    return (
-      <Alert className="big-spacer-top" variant="warning">
-        {translate('onboarding.create_project.no_bbs_repos.filter')}
-      </Alert>
-    );
-  }
-
-  const filteredProjects = projects.filter((p) =>
-    searchResults.some((r) => r.projectKey === p.key)
-  );
-  const filteredProjectKeys = filteredProjects.map((p) => p.key);
-  const filteredSearchResults = searchResults.filter(
-    (r) => !filteredProjectKeys.includes(r.projectKey)
-  );
-
-  return (
-    <div className="big-spacer-top">
-      <DeferredSpinner loading={searching}>
-        {filteredSearchResults.length > 0 && (
-          <BitbucketProjectAccordion
-            disableRepositories={disableRepositories}
-            onSelectRepository={props.onSelectRepository}
-            open={true}
-            repositories={filteredSearchResults}
-            selectedRepository={selectedRepository}
-            showingAllRepositories={true}
-          />
-        )}
-
-        {filteredProjects.map((project) => {
-          const repositories = searchResults.filter((r) => r.projectKey === project.key);
-
-          return (
-            <BitbucketProjectAccordion
-              disableRepositories={disableRepositories}
-              key={project.key}
-              onSelectRepository={props.onSelectRepository}
-              open={true}
-              project={project}
-              repositories={repositories}
-              selectedRepository={selectedRepository}
-              showingAllRepositories={true}
-            />
-          );
-        })}
-      </DeferredSpinner>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx
new file mode 100644 (file)
index 0000000..8b22d21
--- /dev/null
@@ -0,0 +1,110 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { queryToSearch } from '../../../../helpers/urls';
+import {
+  BitbucketProject,
+  BitbucketProjectRepositories,
+  BitbucketRepository,
+} from '../../../../types/alm-integration';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+import BitbucketRepositories from './BitbucketRepositories';
+import BitbucketSearchResults from './BitbucketSearchResults';
+
+export interface BitbucketImportRepositoryFormProps {
+  disableRepositories: boolean;
+  onSearch: (query: string) => void;
+  onSelectRepository: (repo: BitbucketRepository) => void;
+  projects?: BitbucketProject[];
+  projectRepositories?: BitbucketProjectRepositories;
+  searching: boolean;
+  searchResults?: BitbucketRepository[];
+  selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketImportRepositoryForm(props: BitbucketImportRepositoryFormProps) {
+  const {
+    disableRepositories,
+    projects = [],
+    projectRepositories = {},
+    searchResults,
+    searching,
+    selectedRepository,
+  } = props;
+
+  if (projects.length === 0) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.no_bbs_projects')}
+          id="onboarding.create_project.no_bbs_projects"
+          values={{
+            link: (
+              <Link
+                to={{
+                  pathname: '/projects/create',
+                  search: queryToSearch({ mode: CreateProjectModes.BitbucketServer, resetPat: 1 }),
+                }}
+              >
+                {translate('onboarding.create_project.update_your_token')}
+              </Link>
+            ),
+          }}
+        />
+      </Alert>
+    );
+  }
+
+  return (
+    <div className="create-project-import-bbs">
+      <InstanceNewCodeDefinitionComplianceWarning />
+
+      <SearchBox
+        onChange={props.onSearch}
+        placeholder={translate('onboarding.create_project.search_repositories_by_name')}
+      />
+
+      {searching || searchResults ? (
+        <BitbucketSearchResults
+          disableRepositories={disableRepositories}
+          onSelectRepository={props.onSelectRepository}
+          projects={projects}
+          searchResults={searchResults}
+          searching={searching}
+          selectedRepository={selectedRepository}
+        />
+      ) : (
+        <BitbucketRepositories
+          disableRepositories={disableRepositories}
+          onSelectRepository={props.onSelectRepository}
+          projectRepositories={projectRepositories}
+          projects={projects}
+          selectedRepository={selectedRepository}
+        />
+      )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectAccordion.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectAccordion.tsx
new file mode 100644 (file)
index 0000000..df14cad
--- /dev/null
@@ -0,0 +1,150 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import BoxedGroupAccordion from '../../../../components/controls/BoxedGroupAccordion';
+import Radio from '../../../../components/controls/Radio';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import { CreateProjectModes } from '../types';
+
+export interface BitbucketProjectAccordionProps {
+  disableRepositories: boolean;
+  onClick?: () => void;
+  onSelectRepository: (repo: BitbucketRepository) => void;
+  open: boolean;
+  project?: BitbucketProject;
+  repositories: BitbucketRepository[];
+  selectedRepository?: BitbucketRepository;
+  showingAllRepositories: boolean;
+}
+
+export default function BitbucketProjectAccordion(props: BitbucketProjectAccordionProps) {
+  const {
+    disableRepositories,
+    open,
+    project,
+    repositories,
+    selectedRepository,
+    showingAllRepositories,
+  } = props;
+
+  const repositoryCount = repositories.length;
+
+  const title = project?.name ?? translate('search_results');
+
+  return (
+    <BoxedGroupAccordion
+      className={classNames('big-spacer-bottom', {
+        open,
+        'not-clickable': !props.onClick,
+        'no-hover': !props.onClick,
+      })}
+      onClick={
+        props.onClick
+          ? props.onClick
+          : () => {
+              /* noop */
+            }
+      }
+      open={open}
+      title={<h3>{title}</h3>}
+    >
+      {open && (
+        <>
+          <div className="display-flex-wrap">
+            {repositoryCount === 0 && (
+              <Alert variant="warning">
+                <FormattedMessage
+                  defaultMessage={translate('onboarding.create_project.no_bbs_repos')}
+                  id="onboarding.create_project.no_bbs_repos"
+                  values={{
+                    link: (
+                      <Link
+                        to={{
+                          pathname: '/projects/create',
+                          search: queryToSearch({
+                            mode: CreateProjectModes.BitbucketServer,
+                            resetPat: 1,
+                          }),
+                        }}
+                      >
+                        {translate('onboarding.create_project.update_your_token')}
+                      </Link>
+                    ),
+                  }}
+                />
+              </Alert>
+            )}
+
+            {repositories.map((repo) =>
+              repo.sqProjectKey ? (
+                <div
+                  className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo"
+                  key={repo.id}
+                >
+                  <CheckIcon className="spacer-right" fill={colors.green} size={14} />
+                  <div className="overflow-hidden">
+                    <div className="little-spacer-bottom">
+                      <strong title={repo.name}>
+                        <Link to={getProjectUrl(repo.sqProjectKey)}>{repo.name}</Link>
+                      </strong>
+                    </div>
+                    <em>{translate('onboarding.create_project.repository_imported')}</em>
+                  </div>
+                </div>
+              ) : (
+                <Radio
+                  checked={selectedRepository?.id === repo.id}
+                  className={classNames(
+                    'display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden',
+                    {
+                      disabled: disableRepositories,
+                    }
+                  )}
+                  key={repo.id}
+                  onCheck={() => props.onSelectRepository(repo)}
+                  value={String(repo.id)}
+                >
+                  <strong title={repo.name}>{repo.name}</strong>
+                </Radio>
+              )
+            )}
+          </div>
+
+          {!showingAllRepositories && repositoryCount > 0 && (
+            <Alert variant="warning">
+              {translateWithParameters(
+                'onboarding.create_project.only_showing_X_first_repos',
+                repositoryCount
+              )}
+            </Alert>
+          )}
+        </>
+      )}
+    </BoxedGroupAccordion>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
new file mode 100644 (file)
index 0000000..af60cb1
--- /dev/null
@@ -0,0 +1,288 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import {
+  getBitbucketServerProjects,
+  getBitbucketServerRepositories,
+  importBitbucketServerProject,
+  searchForBitbucketServerRepositories,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import {
+  BitbucketProject,
+  BitbucketProjectRepositories,
+  BitbucketRepository,
+} from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
+import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
+
+interface Props {
+  canAdmin: boolean;
+  almInstances: AlmSettingsInstance[];
+  loadingBindings: boolean;
+  onProjectCreate: (projectKey: string) => void;
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  selectedAlmInstance?: AlmSettingsInstance;
+  importing: boolean;
+  loading: boolean;
+  projects?: BitbucketProject[];
+  projectRepositories?: BitbucketProjectRepositories;
+  searching: boolean;
+  searchResults?: BitbucketRepository[];
+  selectedRepository?: BitbucketRepository;
+  showPersonalAccessTokenForm: boolean;
+}
+
+export default class BitbucketProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      // For now, we only handle a single instance. So we always use the first
+      // one from the list.
+      selectedAlmInstance: props.almInstances[0],
+      importing: false,
+      loading: false,
+      searching: false,
+      showPersonalAccessTokenForm: true,
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+      this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () =>
+        this.fetchInitialData()
+      );
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchInitialData = async () => {
+    const { showPersonalAccessTokenForm } = this.state;
+
+    if (!showPersonalAccessTokenForm) {
+      this.setState({ loading: true });
+      const projects = await this.fetchBitbucketProjects().catch(() => undefined);
+
+      let projectRepositories;
+      if (projects && projects.length > 0) {
+        projectRepositories = await this.fetchBitbucketRepositories(projects).catch(
+          () => undefined
+        );
+      }
+
+      if (this.mounted) {
+        this.setState({
+          projects,
+          projectRepositories,
+          loading: false,
+        });
+      }
+    }
+  };
+
+  fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve(undefined);
+    }
+
+    return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
+  };
+
+  fetchBitbucketRepositories = (
+    projects: BitbucketProject[]
+  ): Promise<BitbucketProjectRepositories | undefined> => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve(undefined);
+    }
+
+    return Promise.all(
+      projects.map((p) => {
+        return getBitbucketServerRepositories(selectedAlmInstance.key, p.name).then(
+          ({ isLastPage, repositories }) => {
+            // Because the WS uses the project name rather than its key to find
+            // repositories, we can match more repositories than we expect. For
+            // example, p.name = "A1" would find repositories for projects "A1",
+            // "A10", "A11", etc. This is a limitation of BBS. To make sure we
+            // don't display incorrect information, filter on the project key.
+            const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);
+
+            // And because of the above, the "isLastPage" cannot be relied upon
+            // either. This one is impossible to get 100% for now. We can only
+            // make some assumptions: by default, the page size for BBS is 25
+            // (this is not part of the payload, so we don't know the actual
+            // number; but changing this implies changing some advanced config,
+            // so it's not likely). If the filtered repos is larger than this
+            // number AND isLastPage is false, we'll keep it at false.
+            // Otherwise, we assume it's true.
+            const realIsLastPage =
+              isLastPage || filteredRepositories.length < DEFAULT_BBS_PAGE_SIZE;
+
+            return {
+              repositories: filteredRepositories,
+              isLastPage: realIsLastPage,
+              projectKey: p.key,
+            };
+          }
+        );
+      })
+    ).then((results) => {
+      return results.reduce(
+        (acc: BitbucketProjectRepositories, { isLastPage, projectKey, repositories }) => {
+          return { ...acc, [projectKey]: { allShown: isLastPage, repositories } };
+        },
+        {}
+      );
+    });
+  };
+
+  cleanUrl = () => {
+    const { location, router } = this.props;
+    delete location.query.resetPat;
+    router.replace(location);
+  };
+
+  handlePersonalAccessTokenCreated = async () => {
+    this.setState({ showPersonalAccessTokenForm: false });
+    this.cleanUrl();
+    await this.fetchInitialData();
+  };
+
+  handleImportRepository = () => {
+    const { selectedAlmInstance, selectedRepository } = this.state;
+
+    if (!selectedAlmInstance || !selectedRepository) {
+      return;
+    }
+
+    this.setState({ importing: true });
+    importBitbucketServerProject(
+      selectedAlmInstance.key,
+      selectedRepository.projectKey,
+      selectedRepository.slug
+    )
+      .then(({ project: { key } }) => {
+        if (this.mounted) {
+          this.setState({ importing: false });
+          this.props.onProjectCreate(key);
+        }
+      })
+      .catch(() => {
+        if (this.mounted) {
+          this.setState({ importing: false });
+        }
+      });
+  };
+
+  handleSearch = (query: string) => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return;
+    }
+
+    if (!query) {
+      this.setState({ searching: false, searchResults: undefined, selectedRepository: undefined });
+      return;
+    }
+
+    this.setState({ searching: true, selectedRepository: undefined });
+    searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
+      .then(({ repositories }) => {
+        if (this.mounted) {
+          this.setState({ searching: false, searchResults: repositories });
+        }
+      })
+      .catch(() => {
+        if (this.mounted) {
+          this.setState({ searching: false });
+        }
+      });
+  };
+
+  handleSelectRepository = (selectedRepository: BitbucketRepository) => {
+    this.setState({ selectedRepository });
+  };
+
+  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+    this.setState({
+      selectedAlmInstance: instance,
+      showPersonalAccessTokenForm: true,
+      searching: false,
+      searchResults: undefined,
+    });
+  };
+
+  render() {
+    const { canAdmin, loadingBindings, location, almInstances } = this.props;
+    const {
+      selectedAlmInstance,
+      importing,
+      loading,
+      projectRepositories,
+      projects,
+      searching,
+      searchResults,
+      selectedRepository,
+      showPersonalAccessTokenForm,
+    } = this.state;
+
+    return (
+      <BitbucketCreateProjectRenderer
+        selectedAlmInstance={selectedAlmInstance}
+        almInstances={almInstances}
+        canAdmin={canAdmin}
+        importing={importing}
+        loading={loading || loadingBindings}
+        onImportRepository={this.handleImportRepository}
+        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+        onSearch={this.handleSearch}
+        onSelectRepository={this.handleSelectRepository}
+        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+        projectRepositories={projectRepositories}
+        projects={projects}
+        resetPat={Boolean(location.query.resetPat)}
+        searchResults={searchResults}
+        searching={searching}
+        selectedRepository={selectedRepository}
+        showPersonalAccessTokenForm={
+          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+        }
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx
new file mode 100644 (file)
index 0000000..679c30c
--- /dev/null
@@ -0,0 +1,138 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { Button } from '../../../../components/controls/buttons';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import {
+  BitbucketProject,
+  BitbucketProjectRepositories,
+  BitbucketRepository,
+} from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
+
+export interface BitbucketProjectCreateRendererProps {
+  selectedAlmInstance?: AlmSettingsInstance;
+  almInstances: AlmSettingsInstance[];
+  canAdmin?: boolean;
+  importing: boolean;
+  loading: boolean;
+  onImportRepository: () => void;
+  onSearch: (query: string) => void;
+  onSelectRepository: (repo: BitbucketRepository) => void;
+  onPersonalAccessTokenCreated: () => void;
+  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+  projects?: BitbucketProject[];
+  projectRepositories?: BitbucketProjectRepositories;
+  resetPat: boolean;
+  searching: boolean;
+  searchResults?: BitbucketRepository[];
+  selectedRepository?: BitbucketRepository;
+  showPersonalAccessTokenForm?: boolean;
+}
+
+export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
+  const {
+    almInstances,
+    selectedAlmInstance,
+    canAdmin,
+    importing,
+    loading,
+    projects,
+    projectRepositories,
+    selectedRepository,
+    searching,
+    searchResults,
+    showPersonalAccessTokenForm,
+    resetPat,
+  } = props;
+
+  return (
+    <>
+      <CreateProjectPageHeader
+        additionalActions={
+          !showPersonalAccessTokenForm && (
+            <div className="display-flex-center pull-right">
+              <DeferredSpinner className="spacer-right" loading={importing} />
+              <Button
+                className="button-large button-primary"
+                disabled={!selectedRepository || importing}
+                onClick={props.onImportRepository}
+              >
+                {translate('onboarding.create_project.import_selected_repo')}
+              </Button>
+            </div>
+          )
+        }
+        title={
+          <span className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="24"
+              src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
+            />
+            {translate('onboarding.create_project.from_bbs')}
+          </span>
+        }
+      />
+
+      <AlmSettingsInstanceDropdown
+        almKey={AlmKeys.BitbucketServer}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onChangeConfig={props.onSelectedAlmInstanceChange}
+      />
+
+      {loading && <i className="spinner" />}
+
+      {!loading && !selectedAlmInstance && (
+        <WrongBindingCountAlert alm={AlmKeys.BitbucketServer} canAdmin={!!canAdmin} />
+      )}
+
+      {!loading &&
+        selectedAlmInstance &&
+        (showPersonalAccessTokenForm ? (
+          <PersonalAccessTokenForm
+            almSetting={selectedAlmInstance}
+            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+            resetPat={resetPat}
+          />
+        ) : (
+          <BitbucketImportRepositoryForm
+            disableRepositories={importing}
+            onSearch={props.onSearch}
+            onSelectRepository={props.onSelectRepository}
+            projectRepositories={projectRepositories}
+            projects={projects}
+            searchResults={searchResults}
+            searching={searching}
+            selectedRepository={selectedRepository}
+          />
+        ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx
new file mode 100644 (file)
index 0000000..a7a225b
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { uniq, without } from 'lodash';
+import * as React from 'react';
+import { ButtonLink } from '../../../../components/controls/buttons';
+import { translate } from '../../../../helpers/l10n';
+import {
+  BitbucketProject,
+  BitbucketProjectRepositories,
+  BitbucketRepository,
+} from '../../../../types/alm-integration';
+import BitbucketProjectAccordion from './BitbucketProjectAccordion';
+
+export interface BitbucketRepositoriesProps {
+  disableRepositories: boolean;
+  onSelectRepository: (repo: BitbucketRepository) => void;
+  projects: BitbucketProject[];
+  projectRepositories: BitbucketProjectRepositories;
+  selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
+  const { disableRepositories, projects, projectRepositories, selectedRepository } = props;
+
+  const [openProjectKeys, setOpenProjectKeys] = React.useState(
+    projects.length > 0 ? [projects[0].key] : []
+  );
+
+  const allAreExpanded = projects.length <= openProjectKeys.length;
+
+  const handleClick = (isOpen: boolean, projectKey: string) => {
+    setOpenProjectKeys(
+      isOpen ? without(openProjectKeys, projectKey) : uniq([...openProjectKeys, projectKey])
+    );
+  };
+
+  return (
+    <>
+      <div className="overflow-hidden spacer-bottom">
+        <ButtonLink
+          className="pull-right"
+          onClick={() => setOpenProjectKeys(allAreExpanded ? [] : projects.map((p) => p.key))}
+        >
+          {allAreExpanded ? translate('collapse_all') : translate('expand_all')}
+        </ButtonLink>
+      </div>
+
+      {projects.map((project) => {
+        const isOpen = openProjectKeys.includes(project.key);
+        const { allShown, repositories = [] } = projectRepositories[project.key] || {};
+
+        return (
+          <BitbucketProjectAccordion
+            disableRepositories={disableRepositories}
+            key={project.key}
+            onClick={() => handleClick(isOpen, project.key)}
+            onSelectRepository={props.onSelectRepository}
+            open={isOpen}
+            project={project}
+            repositories={repositories}
+            selectedRepository={selectedRepository}
+            showingAllRepositories={allShown}
+          />
+        );
+      })}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketSearchResults.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketSearchResults.tsx
new file mode 100644 (file)
index 0000000..93d71e0
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import BitbucketProjectAccordion from './BitbucketProjectAccordion';
+
+export interface BitbucketSearchResultsProps {
+  disableRepositories: boolean;
+  onSelectRepository: (repo: BitbucketRepository) => void;
+  projects: BitbucketProject[];
+  searching: boolean;
+  searchResults?: BitbucketRepository[];
+  selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketSearchResults(props: BitbucketSearchResultsProps) {
+  const {
+    disableRepositories,
+    projects,
+    searching,
+    searchResults = [],
+    selectedRepository,
+  } = props;
+
+  if (searchResults.length === 0 && !searching) {
+    return (
+      <Alert className="big-spacer-top" variant="warning">
+        {translate('onboarding.create_project.no_bbs_repos.filter')}
+      </Alert>
+    );
+  }
+
+  const filteredProjects = projects.filter((p) =>
+    searchResults.some((r) => r.projectKey === p.key)
+  );
+  const filteredProjectKeys = filteredProjects.map((p) => p.key);
+  const filteredSearchResults = searchResults.filter(
+    (r) => !filteredProjectKeys.includes(r.projectKey)
+  );
+
+  return (
+    <div className="big-spacer-top">
+      <DeferredSpinner loading={searching}>
+        {filteredSearchResults.length > 0 && (
+          <BitbucketProjectAccordion
+            disableRepositories={disableRepositories}
+            onSelectRepository={props.onSelectRepository}
+            open={true}
+            repositories={filteredSearchResults}
+            selectedRepository={selectedRepository}
+            showingAllRepositories={true}
+          />
+        )}
+
+        {filteredProjects.map((project) => {
+          const repositories = searchResults.filter((r) => r.projectKey === project.key);
+
+          return (
+            <BitbucketProjectAccordion
+              disableRepositories={disableRepositories}
+              key={project.key}
+              onSelectRepository={props.onSelectRepository}
+              open={true}
+              project={project}
+              repositories={repositories}
+              selectedRepository={selectedRepository}
+              showingAllRepositories={true}
+            />
+          );
+        })}
+      </DeferredSpinner>
+    </div>
+  );
+}
index 08fc333e5d77e9492ed00e6b623b8e21ccc27a52..1c92d7895a1f338d952fa0e0bbaa356e1b384562 100644 (file)
@@ -32,13 +32,13 @@ import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import { AppState } from '../../../types/appstate';
 import { Feature } from '../../../types/features';
 import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
-import AzureProjectCreate from './AzureProjectCreate';
-import BitbucketCloudProjectCreate from './BitbucketCloudProjectCreate';
-import BitbucketProjectCreate from './BitbucketProjectCreate';
+import AzureProjectCreate from './Azure/AzureProjectCreate';
+import BitbucketCloudProjectCreate from './BitbucketCloud/BitbucketCloudProjectCreate';
+import BitbucketProjectCreate from './BitbucketServer/BitbucketProjectCreate';
 import CreateProjectModeSelection from './CreateProjectModeSelection';
-import GitHubProjectCreate from './GitHubProjectCreate';
-import GitlabProjectCreate from './GitlabProjectCreate';
-import ManualProjectCreate from './ManualProjectCreate';
+import GitHubProjectCreate from './Github/GitHubProjectCreate';
+import GitlabProjectCreate from './Gitlab/GitlabProjectCreate';
+import ManualProjectCreate from './manual/ManualProjectCreate';
 import './style.css';
 import { CreateProjectModes } from './types';
 
diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx
deleted file mode 100644 (file)
index f5d4517..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-
-export interface CreateProjectPageHeaderProps {
-  additionalActions?: React.ReactNode;
-  title: React.ReactNode;
-}
-
-export default function CreateProjectPageHeader(props: CreateProjectPageHeaderProps) {
-  const { additionalActions, title } = props;
-
-  return (
-    <header className="huge-spacer-bottom bordered-bottom overflow-hidden">
-      <h1 className="pull-left huge big-spacer-bottom">{title}</h1>
-
-      {additionalActions}
-    </header>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx
deleted file mode 100644 (file)
index 0174873..0000000
+++ /dev/null
@@ -1,340 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { debounce } from 'lodash';
-import * as React from 'react';
-import { isWebUri } from 'valid-url';
-import {
-  getGithubClientId,
-  getGithubOrganizations,
-  getGithubRepositories,
-  importGithubRepository,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { getHostUrl } from '../../../helpers/urls';
-import { GithubOrganization, GithubRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
-
-interface Props {
-  canAdmin: boolean;
-  loadingBindings: boolean;
-  onProjectCreate: (projectKey: string) => void;
-  almInstances: AlmSettingsInstance[];
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  error: boolean;
-  importing: boolean;
-  loadingOrganizations: boolean;
-  loadingRepositories: boolean;
-  organizations: GithubOrganization[];
-  repositoryPaging: Paging;
-  repositories: GithubRepository[];
-  searchQuery: string;
-  selectedOrganization?: GithubOrganization;
-  selectedRepository?: GithubRepository;
-  selectedAlmInstance?: AlmSettingsInstance;
-}
-
-const REPOSITORY_PAGE_SIZE = 30;
-
-export default class GitHubProjectCreate extends React.Component<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      error: false,
-      importing: 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();
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () =>
-        this.initialize()
-      );
-    }
-  }
-
-  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];
-  }
-
-  async initialize() {
-    const { location, router } = this.props;
-    const { selectedAlmInstance } = this.state;
-    if (!selectedAlmInstance || !selectedAlmInstance.url) {
-      this.setState({ error: true });
-      return;
-    }
-    this.setState({ error: false });
-
-    const code = location.query?.code;
-    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 });
-      }
-    }
-  }
-
-  async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) {
-    if (!selectedAlmInstance.url) {
-      return;
-    }
-
-    const { clientId } = await getGithubClientId(selectedAlmInstance.key);
-
-    if (!clientId) {
-      this.setState({ error: true });
-      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);
-
-    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 });
-      return;
-    }
-
-    this.setState({ loadingRepositories: true });
-
-    try {
-      const data = await getGithubRepositories({
-        almSetting: selectedAlmInstance.key,
-        organization: organizationKey,
-        pageSize: REPOSITORY_PAGE_SIZE,
-        page,
-        query,
-      });
-
-      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: [],
-        });
-      }
-    }
-  }
-
-  triggerSearch = (query: string) => {
-    const { selectedOrganization } = this.state;
-    if (selectedOrganization) {
-      this.setState({ selectedRepository: undefined });
-      this.fetchRepositories({ organizationKey: selectedOrganization.key, query });
-    }
-  };
-
-  handleSelectOrganization = (key: string) => {
-    this.setState(({ organizations }) => ({
-      searchQuery: '',
-      selectedRepository: undefined,
-      selectedOrganization: organizations.find((o) => o.key === key),
-    }));
-    this.fetchRepositories({ organizationKey: key });
-  };
-
-  handleSelectRepository = (key: string) => {
-    this.setState(({ repositories }) => ({
-      selectedRepository: repositories?.find((r) => r.key === 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 = async () => {
-    const { selectedOrganization, selectedRepository, selectedAlmInstance } = this.state;
-
-    if (selectedAlmInstance && selectedOrganization && selectedRepository) {
-      this.setState({ importing: true });
-
-      try {
-        const { project } = await importGithubRepository(
-          selectedAlmInstance.key,
-          selectedOrganization.key,
-          selectedRepository.key
-        );
-
-        this.props.onProjectCreate(project.key);
-      } finally {
-        if (this.mounted) {
-          this.setState({ importing: false });
-        }
-      }
-    }
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState(
-      { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] },
-      () => this.initialize()
-    );
-  };
-
-  render() {
-    const { canAdmin, loadingBindings, almInstances } = this.props;
-    const {
-      error,
-      importing,
-      loadingOrganizations,
-      loadingRepositories,
-      organizations,
-      repositoryPaging,
-      repositories,
-      searchQuery,
-      selectedOrganization,
-      selectedRepository,
-      selectedAlmInstance,
-    } = this.state;
-
-    return (
-      <GitHubProjectCreateRenderer
-        canAdmin={canAdmin}
-        error={error}
-        importing={importing}
-        loadingBindings={loadingBindings}
-        loadingOrganizations={loadingOrganizations}
-        loadingRepositories={loadingRepositories}
-        onImportRepository={this.handleImportRepository}
-        onLoadMore={this.handleLoadMore}
-        onSearch={this.handleSearch}
-        onSelectOrganization={this.handleSelectOrganization}
-        onSelectRepository={this.handleSelectRepository}
-        organizations={organizations}
-        repositoryPaging={repositoryPaging}
-        searchQuery={searchQuery}
-        repositories={repositories}
-        selectedOrganization={selectedOrganization}
-        selectedRepository={selectedRepository}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx
deleted file mode 100644 (file)
index 95b7fbb..0000000
+++ /dev/null
@@ -1,301 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 react/no-unused-prop-types */
-
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import ListFooter from '../../../components/controls/ListFooter';
-import Radio from '../../../components/controls/Radio';
-import SearchBox from '../../../components/controls/SearchBox';
-import Select, { LabelValueSelectOption } from '../../../components/controls/Select';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { getProjectUrl } from '../../../helpers/urls';
-import { GithubOrganization, GithubRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { ComponentQualifier } from '../../../types/component';
-import { Paging } from '../../../types/types';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-
-export interface GitHubProjectCreateRendererProps {
-  canAdmin: boolean;
-  error: boolean;
-  importing: boolean;
-  loadingBindings: boolean;
-  loadingOrganizations: boolean;
-  loadingRepositories: boolean;
-  onImportRepository: () => void;
-  onLoadMore: () => void;
-  onSearch: (q: string) => void;
-  onSelectOrganization: (key: string) => void;
-  onSelectRepository: (key: string) => void;
-  organizations: GithubOrganization[];
-  repositories?: GithubRepository[];
-  repositoryPaging: Paging;
-  searchQuery: string;
-  selectedOrganization?: GithubOrganization;
-  selectedRepository?: GithubRepository;
-  almInstances: AlmSettingsInstance[];
-  selectedAlmInstance?: AlmSettingsInstance;
-  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-}
-
-function orgToOption({ key, name }: GithubOrganization) {
-  return { value: key, label: name };
-}
-
-function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
-  const {
-    importing,
-    loadingRepositories,
-    repositories,
-    repositoryPaging,
-    searchQuery,
-    selectedOrganization,
-    selectedRepository,
-  } = props;
-
-  const isChecked = (repository: GithubRepository) =>
-    !!repository.sqProjectKey ||
-    (!!selectedRepository && selectedRepository.key === repository.key);
-
-  const isDisabled = (repository: GithubRepository) =>
-    !!repository.sqProjectKey || loadingRepositories || importing;
-
-  return (
-    selectedOrganization &&
-    repositories && (
-      <div className="boxed-group padded display-flex-wrap">
-        <div className="width-100">
-          <SearchBox
-            className="big-spacer-bottom"
-            onChange={props.onSearch}
-            placeholder={translate('onboarding.create_project.search_repositories')}
-            value={searchQuery}
-          />
-        </div>
-
-        {repositories.length === 0 ? (
-          <div className="padded">
-            <DeferredSpinner loading={loadingRepositories}>
-              {translate('no_results')}
-            </DeferredSpinner>
-          </div>
-        ) : (
-          repositories.map((r) => (
-            <Radio
-              className="spacer-top spacer-bottom padded create-project-github-repository"
-              key={r.key}
-              checked={isChecked(r)}
-              disabled={isDisabled(r)}
-              value={r.key}
-              onCheck={props.onSelectRepository}
-            >
-              <div className="big overflow-hidden max-width-100" title={r.name}>
-                <div className="text-ellipsis">
-                  {r.sqProjectKey ? (
-                    <div className="display-flex-center max-width-100">
-                      <Link
-                        className="display-flex-center max-width-60"
-                        to={getProjectUrl(r.sqProjectKey)}
-                      >
-                        <QualifierIcon
-                          className="spacer-right"
-                          qualifier={ComponentQualifier.Project}
-                        />
-                        <span className="text-ellipsis">{r.name}</span>
-                      </Link>
-                      <em className="display-flex-center small big-spacer-left flex-0">
-                        <span className="text-muted-2">
-                          {translate('onboarding.create_project.repository_imported')}
-                        </span>
-                        <CheckIcon className="little-spacer-left" size={12} fill={colors.green} />
-                      </em>
-                    </div>
-                  ) : (
-                    r.name
-                  )}
-                </div>
-                {r.url && (
-                  <a
-                    className="notice small display-flex-center little-spacer-top"
-                    onClick={(e) => e.stopPropagation()}
-                    target="_blank"
-                    href={r.url}
-                    rel="noopener noreferrer"
-                  >
-                    {translate('onboarding.create_project.see_on_github')}
-                  </a>
-                )}
-              </div>
-            </Radio>
-          ))
-        )}
-
-        <div className="display-flex-justify-center width-100">
-          <ListFooter
-            count={repositories.length}
-            total={repositoryPaging.total}
-            loadMore={props.onLoadMore}
-            loading={loadingRepositories}
-          />
-        </div>
-      </div>
-    )
-  );
-}
-
-export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
-  const {
-    canAdmin,
-    error,
-    importing,
-    loadingBindings,
-    loadingOrganizations,
-    organizations,
-    selectedOrganization,
-    selectedRepository,
-    almInstances,
-    selectedAlmInstance,
-  } = props;
-
-  if (loadingBindings) {
-    return <DeferredSpinner />;
-  }
-
-  return (
-    <div>
-      <CreateProjectPageHeader
-        additionalActions={
-          selectedOrganization && (
-            <div className="display-flex-center pull-right">
-              <DeferredSpinner className="spacer-right" loading={importing} />
-              <Button
-                className="button-large button-primary"
-                disabled={!selectedRepository || importing}
-                onClick={props.onImportRepository}
-              >
-                {translate('onboarding.create_project.import_selected_repo')}
-              </Button>
-            </div>
-          )
-        }
-        title={
-          <span className="text-middle display-flex-center">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height={24}
-              src={`${getBaseUrl()}/images/alm/github.svg`}
-            />
-            {translate('onboarding.create_project.github.title')}
-          </span>
-        }
-      />
-
-      <AlmSettingsInstanceDropdown
-        almKey={AlmKeys.GitHub}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onChangeConfig={props.onSelectedAlmInstanceChange}
-      />
-
-      {error && selectedAlmInstance && (
-        <div className="display-flex-justify-center">
-          <div className="boxed-group padded width-50 huge-spacer-top">
-            <h2 className="big-spacer-bottom">
-              {translate('onboarding.create_project.github.warning.title')}
-            </h2>
-            <Alert variant="warning">
-              {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')
-              )}
-            </Alert>
-          </div>
-        </div>
-      )}
-
-      {!error && (
-        <DeferredSpinner loading={loadingOrganizations}>
-          <div className="form-field">
-            <label htmlFor="github-choose-organization">
-              {translate('onboarding.create_project.github.choose_organization')}
-            </label>
-            {organizations.length > 0 ? (
-              <Select
-                inputId="github-choose-organization"
-                className="input-super-large"
-                options={organizations.map(orgToOption)}
-                onChange={({ value }: LabelValueSelectOption) => props.onSelectOrganization(value)}
-                value={selectedOrganization ? orgToOption(selectedOrganization) : null}
-              />
-            ) : (
-              !loadingOrganizations && (
-                <Alert className="spacer-top" variant="error">
-                  {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')
-                  )}
-                </Alert>
-              )
-            )}
-          </div>
-        </DeferredSpinner>
-      )}
-
-      {renderRepositoryList(props)}
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
new file mode 100644 (file)
index 0000000..ec40e48
--- /dev/null
@@ -0,0 +1,340 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { debounce } from 'lodash';
+import * as React from 'react';
+import { isWebUri } from 'valid-url';
+import {
+  getGithubClientId,
+  getGithubOrganizations,
+  getGithubRepositories,
+  importGithubRepository,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { getHostUrl } from '../../../../helpers/urls';
+import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
+
+interface Props {
+  canAdmin: boolean;
+  loadingBindings: boolean;
+  onProjectCreate: (projectKey: string) => void;
+  almInstances: AlmSettingsInstance[];
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  error: boolean;
+  importing: boolean;
+  loadingOrganizations: boolean;
+  loadingRepositories: boolean;
+  organizations: GithubOrganization[];
+  repositoryPaging: Paging;
+  repositories: GithubRepository[];
+  searchQuery: string;
+  selectedOrganization?: GithubOrganization;
+  selectedRepository?: GithubRepository;
+  selectedAlmInstance?: AlmSettingsInstance;
+}
+
+const REPOSITORY_PAGE_SIZE = 30;
+
+export default class GitHubProjectCreate extends React.Component<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      error: false,
+      importing: 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();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+      this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () =>
+        this.initialize()
+      );
+    }
+  }
+
+  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];
+  }
+
+  async initialize() {
+    const { location, router } = this.props;
+    const { selectedAlmInstance } = this.state;
+    if (!selectedAlmInstance || !selectedAlmInstance.url) {
+      this.setState({ error: true });
+      return;
+    }
+    this.setState({ error: false });
+
+    const code = location.query?.code;
+    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 });
+      }
+    }
+  }
+
+  async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) {
+    if (!selectedAlmInstance.url) {
+      return;
+    }
+
+    const { clientId } = await getGithubClientId(selectedAlmInstance.key);
+
+    if (!clientId) {
+      this.setState({ error: true });
+      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);
+
+    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 });
+      return;
+    }
+
+    this.setState({ loadingRepositories: true });
+
+    try {
+      const data = await getGithubRepositories({
+        almSetting: selectedAlmInstance.key,
+        organization: organizationKey,
+        pageSize: REPOSITORY_PAGE_SIZE,
+        page,
+        query,
+      });
+
+      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: [],
+        });
+      }
+    }
+  }
+
+  triggerSearch = (query: string) => {
+    const { selectedOrganization } = this.state;
+    if (selectedOrganization) {
+      this.setState({ selectedRepository: undefined });
+      this.fetchRepositories({ organizationKey: selectedOrganization.key, query });
+    }
+  };
+
+  handleSelectOrganization = (key: string) => {
+    this.setState(({ organizations }) => ({
+      searchQuery: '',
+      selectedRepository: undefined,
+      selectedOrganization: organizations.find((o) => o.key === key),
+    }));
+    this.fetchRepositories({ organizationKey: key });
+  };
+
+  handleSelectRepository = (key: string) => {
+    this.setState(({ repositories }) => ({
+      selectedRepository: repositories?.find((r) => r.key === 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 = async () => {
+    const { selectedOrganization, selectedRepository, selectedAlmInstance } = this.state;
+
+    if (selectedAlmInstance && selectedOrganization && selectedRepository) {
+      this.setState({ importing: true });
+
+      try {
+        const { project } = await importGithubRepository(
+          selectedAlmInstance.key,
+          selectedOrganization.key,
+          selectedRepository.key
+        );
+
+        this.props.onProjectCreate(project.key);
+      } finally {
+        if (this.mounted) {
+          this.setState({ importing: false });
+        }
+      }
+    }
+  };
+
+  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+    this.setState(
+      { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] },
+      () => this.initialize()
+    );
+  };
+
+  render() {
+    const { canAdmin, loadingBindings, almInstances } = this.props;
+    const {
+      error,
+      importing,
+      loadingOrganizations,
+      loadingRepositories,
+      organizations,
+      repositoryPaging,
+      repositories,
+      searchQuery,
+      selectedOrganization,
+      selectedRepository,
+      selectedAlmInstance,
+    } = this.state;
+
+    return (
+      <GitHubProjectCreateRenderer
+        canAdmin={canAdmin}
+        error={error}
+        importing={importing}
+        loadingBindings={loadingBindings}
+        loadingOrganizations={loadingOrganizations}
+        loadingRepositories={loadingRepositories}
+        onImportRepository={this.handleImportRepository}
+        onLoadMore={this.handleLoadMore}
+        onSearch={this.handleSearch}
+        onSelectOrganization={this.handleSelectOrganization}
+        onSelectRepository={this.handleSelectRepository}
+        organizations={organizations}
+        repositoryPaging={repositoryPaging}
+        searchQuery={searchQuery}
+        repositories={repositories}
+        selectedOrganization={selectedOrganization}
+        selectedRepository={selectedRepository}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
new file mode 100644 (file)
index 0000000..ed2d238
--- /dev/null
@@ -0,0 +1,307 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 react/no-unused-prop-types */
+
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import Radio from '../../../../components/controls/Radio';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Select, { LabelValueSelectOption } from '../../../../components/controls/Select';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { getProjectUrl } from '../../../../helpers/urls';
+import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { ComponentQualifier } from '../../../../types/component';
+import { Paging } from '../../../../types/types';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+
+export interface GitHubProjectCreateRendererProps {
+  canAdmin: boolean;
+  error: boolean;
+  importing: boolean;
+  loadingBindings: boolean;
+  loadingOrganizations: boolean;
+  loadingRepositories: boolean;
+  onImportRepository: () => void;
+  onLoadMore: () => void;
+  onSearch: (q: string) => void;
+  onSelectOrganization: (key: string) => void;
+  onSelectRepository: (key: string) => void;
+  organizations: GithubOrganization[];
+  repositories?: GithubRepository[];
+  repositoryPaging: Paging;
+  searchQuery: string;
+  selectedOrganization?: GithubOrganization;
+  selectedRepository?: GithubRepository;
+  almInstances: AlmSettingsInstance[];
+  selectedAlmInstance?: AlmSettingsInstance;
+  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+}
+
+function orgToOption({ key, name }: GithubOrganization) {
+  return { value: key, label: name };
+}
+
+function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
+  const {
+    importing,
+    loadingRepositories,
+    repositories,
+    repositoryPaging,
+    searchQuery,
+    selectedOrganization,
+    selectedRepository,
+  } = props;
+
+  const isChecked = (repository: GithubRepository) =>
+    !!repository.sqProjectKey ||
+    (!!selectedRepository && selectedRepository.key === repository.key);
+
+  const isDisabled = (repository: GithubRepository) =>
+    !!repository.sqProjectKey || loadingRepositories || importing;
+
+  return (
+    selectedOrganization &&
+    repositories && (
+      <div className="boxed-group padded display-flex-wrap">
+        <div className="width-100">
+          <SearchBox
+            className="big-spacer-bottom"
+            onChange={props.onSearch}
+            placeholder={translate('onboarding.create_project.search_repositories')}
+            value={searchQuery}
+          />
+        </div>
+
+        {repositories.length === 0 ? (
+          <div className="padded">
+            <DeferredSpinner loading={loadingRepositories}>
+              {translate('no_results')}
+            </DeferredSpinner>
+          </div>
+        ) : (
+          repositories.map((r) => (
+            <Radio
+              className="spacer-top spacer-bottom padded create-project-github-repository"
+              key={r.key}
+              checked={isChecked(r)}
+              disabled={isDisabled(r)}
+              value={r.key}
+              onCheck={props.onSelectRepository}
+            >
+              <div className="big overflow-hidden max-width-100" title={r.name}>
+                <div className="text-ellipsis">
+                  {r.sqProjectKey ? (
+                    <div className="display-flex-center max-width-100">
+                      <Link
+                        className="display-flex-center max-width-60"
+                        to={getProjectUrl(r.sqProjectKey)}
+                      >
+                        <QualifierIcon
+                          className="spacer-right"
+                          qualifier={ComponentQualifier.Project}
+                        />
+                        <span className="text-ellipsis">{r.name}</span>
+                      </Link>
+                      <em className="display-flex-center small big-spacer-left flex-0">
+                        <span className="text-muted-2">
+                          {translate('onboarding.create_project.repository_imported')}
+                        </span>
+                        <CheckIcon className="little-spacer-left" size={12} fill={colors.green} />
+                      </em>
+                    </div>
+                  ) : (
+                    r.name
+                  )}
+                </div>
+                {r.url && (
+                  <a
+                    className="notice small display-flex-center little-spacer-top"
+                    onClick={(e) => e.stopPropagation()}
+                    target="_blank"
+                    href={r.url}
+                    rel="noopener noreferrer"
+                  >
+                    {translate('onboarding.create_project.see_on_github')}
+                  </a>
+                )}
+              </div>
+            </Radio>
+          ))
+        )}
+
+        <div className="display-flex-justify-center width-100">
+          <ListFooter
+            count={repositories.length}
+            total={repositoryPaging.total}
+            loadMore={props.onLoadMore}
+            loading={loadingRepositories}
+          />
+        </div>
+      </div>
+    )
+  );
+}
+
+export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
+  const {
+    canAdmin,
+    error,
+    importing,
+    loadingBindings,
+    loadingOrganizations,
+    organizations,
+    selectedOrganization,
+    selectedRepository,
+    almInstances,
+    selectedAlmInstance,
+  } = props;
+
+  if (loadingBindings) {
+    return <DeferredSpinner />;
+  }
+
+  return (
+    <div>
+      <CreateProjectPageHeader
+        additionalActions={
+          selectedOrganization && (
+            <div className="display-flex-center pull-right">
+              <DeferredSpinner className="spacer-right" loading={importing} />
+              <Button
+                className="button-large button-primary"
+                disabled={!selectedRepository || importing}
+                onClick={props.onImportRepository}
+              >
+                {translate('onboarding.create_project.import_selected_repo')}
+              </Button>
+            </div>
+          )
+        }
+        title={
+          <span className="text-middle display-flex-center">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height={24}
+              src={`${getBaseUrl()}/images/alm/github.svg`}
+            />
+            {translate('onboarding.create_project.github.title')}
+          </span>
+        }
+      />
+
+      <AlmSettingsInstanceDropdown
+        almKey={AlmKeys.GitHub}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onChangeConfig={props.onSelectedAlmInstanceChange}
+      />
+
+      {error && selectedAlmInstance && (
+        <div className="display-flex-justify-center">
+          <div className="boxed-group padded width-50 huge-spacer-top">
+            <h2 className="big-spacer-bottom">
+              {translate('onboarding.create_project.github.warning.title')}
+            </h2>
+            <Alert variant="warning">
+              {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')
+              )}
+            </Alert>
+          </div>
+        </div>
+      )}
+
+      {!error && (
+        <>
+          <InstanceNewCodeDefinitionComplianceWarning />
+          <DeferredSpinner loading={loadingOrganizations}>
+            <div className="form-field">
+              <label htmlFor="github-choose-organization">
+                {translate('onboarding.create_project.github.choose_organization')}
+              </label>
+              {organizations.length > 0 ? (
+                <Select
+                  inputId="github-choose-organization"
+                  className="input-super-large"
+                  options={organizations.map(orgToOption)}
+                  onChange={({ value }: LabelValueSelectOption) =>
+                    props.onSelectOrganization(value)
+                  }
+                  value={selectedOrganization ? orgToOption(selectedOrganization) : null}
+                />
+              ) : (
+                !loadingOrganizations && (
+                  <Alert className="spacer-top" variant="error">
+                    {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')
+                    )}
+                  </Alert>
+                )
+              )}
+            </div>
+          </DeferredSpinner>
+        </>
+      )}
+
+      {renderRepositoryList(props)}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
new file mode 100644 (file)
index 0000000..45b7aa4
--- /dev/null
@@ -0,0 +1,256 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { getGitlabProjects, importGitlabProject } from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
+
+interface Props {
+  canAdmin: boolean;
+  loadingBindings: boolean;
+  onProjectCreate: (projectKey: string) => void;
+  almInstances: AlmSettingsInstance[];
+  location: Location;
+  router: Router;
+}
+
+interface State {
+  importingGitlabProjectId?: string;
+  loading: boolean;
+  loadingMore: boolean;
+  projects?: GitlabProject[];
+  projectsPaging: Paging;
+  resetPat: boolean;
+  searching: boolean;
+  searchQuery: string;
+  selectedAlmInstance: AlmSettingsInstance;
+  showPersonalAccessTokenForm: boolean;
+}
+
+const GITLAB_PROJECTS_PAGESIZE = 30;
+
+export default class GitlabProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      loading: false,
+      loadingMore: false,
+      projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE },
+      resetPat: false,
+      showPersonalAccessTokenForm: true,
+      searching: false,
+      searchQuery: '',
+      selectedAlmInstance: props.almInstances[0],
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const { almInstances } = this.props;
+    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+      this.setState({ selectedAlmInstance: almInstances[0] }, () => this.fetchInitialData());
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchInitialData = async () => {
+    const { showPersonalAccessTokenForm } = this.state;
+
+    if (!showPersonalAccessTokenForm) {
+      this.setState({ loading: true });
+      const result = await this.fetchProjects();
+      if (this.mounted && result) {
+        const { projects, projectsPaging } = result;
+
+        this.setState({
+          loading: false,
+          projects,
+          projectsPaging,
+        });
+      } else {
+        this.setState({
+          loading: false,
+        });
+      }
+    }
+  };
+
+  handleError = () => {
+    if (this.mounted) {
+      this.setState({ resetPat: true, showPersonalAccessTokenForm: true });
+    }
+
+    return undefined;
+  };
+
+  fetchProjects = async (pageIndex = 1, query?: string) => {
+    const { selectedAlmInstance } = this.state;
+    if (!selectedAlmInstance) {
+      return Promise.resolve(undefined);
+    }
+
+    try {
+      return await getGitlabProjects({
+        almSetting: selectedAlmInstance.key,
+        page: pageIndex,
+        pageSize: GITLAB_PROJECTS_PAGESIZE,
+        query,
+      });
+    } catch (_) {
+      return this.handleError();
+    }
+  };
+
+  doImport = async (gitlabProjectId: string) => {
+    const { selectedAlmInstance } = this.state;
+
+    if (!selectedAlmInstance) {
+      return Promise.resolve(undefined);
+    }
+
+    try {
+      return await importGitlabProject({
+        almSetting: selectedAlmInstance.key,
+        gitlabProjectId,
+      });
+    } catch (_) {
+      return this.handleError();
+    }
+  };
+
+  handleImport = async (gitlabProjectId: string) => {
+    this.setState({ importingGitlabProjectId: gitlabProjectId });
+
+    const result = await this.doImport(gitlabProjectId);
+
+    if (this.mounted) {
+      this.setState({ importingGitlabProjectId: undefined });
+
+      if (result) {
+        this.props.onProjectCreate(result.project.key);
+      }
+    }
+  };
+
+  handleLoadMore = async () => {
+    this.setState({ loadingMore: true });
+
+    const {
+      projectsPaging: { pageIndex },
+      searchQuery,
+    } = this.state;
+
+    const result = await this.fetchProjects(pageIndex + 1, searchQuery);
+    if (this.mounted) {
+      this.setState(({ projects = [], projectsPaging }) => ({
+        loadingMore: false,
+        projects: result ? [...projects, ...result.projects] : projects,
+        projectsPaging: result ? result.projectsPaging : projectsPaging,
+      }));
+    }
+  };
+
+  handleSearch = async (searchQuery: string) => {
+    this.setState({ searching: true, searchQuery });
+
+    const result = await this.fetchProjects(1, searchQuery);
+    if (this.mounted) {
+      this.setState(({ projects, projectsPaging }) => ({
+        searching: false,
+        projects: result ? result.projects : projects,
+        projectsPaging: result ? result.projectsPaging : projectsPaging,
+      }));
+    }
+  };
+
+  cleanUrl = () => {
+    const { location, router } = this.props;
+    delete location.query.resetPat;
+    router.replace(location);
+  };
+
+  handlePersonalAccessTokenCreated = async () => {
+    this.setState({ showPersonalAccessTokenForm: false, resetPat: false });
+    this.cleanUrl();
+    await this.fetchInitialData();
+  };
+
+  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+    this.setState({
+      selectedAlmInstance: instance,
+      showPersonalAccessTokenForm: true,
+      projects: undefined,
+      resetPat: false,
+      searchQuery: '',
+    });
+  };
+
+  render() {
+    const { loadingBindings, location, almInstances, canAdmin } = this.props;
+    const {
+      importingGitlabProjectId,
+      loading,
+      loadingMore,
+      projects,
+      projectsPaging,
+      resetPat,
+      searching,
+      searchQuery,
+      selectedAlmInstance,
+      showPersonalAccessTokenForm,
+    } = this.state;
+
+    return (
+      <GitlabProjectCreateRenderer
+        canAdmin={canAdmin}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        importingGitlabProjectId={importingGitlabProjectId}
+        loading={loading || loadingBindings}
+        loadingMore={loadingMore}
+        onImport={this.handleImport}
+        onLoadMore={this.handleLoadMore}
+        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+        onSearch={this.handleSearch}
+        projects={projects}
+        projectsPaging={projectsPaging}
+        resetPat={resetPat || Boolean(location.query.resetPat)}
+        searching={searching}
+        searchQuery={searchQuery}
+        showPersonalAccessTokenForm={
+          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+        }
+        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
new file mode 100644 (file)
index 0000000..366f561
--- /dev/null
@@ -0,0 +1,120 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';
+
+export interface GitlabProjectCreateRendererProps {
+  canAdmin?: boolean;
+  importingGitlabProjectId?: string;
+  loading: boolean;
+  loadingMore: boolean;
+  onImport: (gitlabProjectId: string) => void;
+  onLoadMore: () => void;
+  onPersonalAccessTokenCreated: () => void;
+  onSearch: (searchQuery: string) => void;
+  projects?: GitlabProject[];
+  projectsPaging: Paging;
+  resetPat: boolean;
+  searching: boolean;
+  searchQuery: string;
+  almInstances?: AlmSettingsInstance[];
+  selectedAlmInstance?: AlmSettingsInstance;
+  showPersonalAccessTokenForm?: boolean;
+  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+}
+
+export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
+  const {
+    canAdmin,
+    importingGitlabProjectId,
+    loading,
+    loadingMore,
+    projects,
+    projectsPaging,
+    resetPat,
+    searching,
+    searchQuery,
+    selectedAlmInstance,
+    almInstances,
+    showPersonalAccessTokenForm,
+  } = props;
+
+  return (
+    <>
+      <CreateProjectPageHeader
+        title={
+          <span className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="24"
+              src={`${getBaseUrl()}/images/alm/gitlab.svg`}
+            />
+            {translate('onboarding.create_project.gitlab.title')}
+          </span>
+        }
+      />
+
+      <AlmSettingsInstanceDropdown
+        almKey={AlmKeys.GitLab}
+        almInstances={almInstances}
+        selectedAlmInstance={selectedAlmInstance}
+        onChangeConfig={props.onSelectedAlmInstanceChange}
+      />
+
+      {loading && <i className="spinner" />}
+
+      {!loading && !selectedAlmInstance && (
+        <WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />
+      )}
+
+      {!loading &&
+        selectedAlmInstance &&
+        (showPersonalAccessTokenForm ? (
+          <PersonalAccessTokenForm
+            almSetting={selectedAlmInstance}
+            resetPat={resetPat}
+            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+          />
+        ) : (
+          <GitlabProjectSelectionForm
+            importingGitlabProjectId={importingGitlabProjectId}
+            loadingMore={loadingMore}
+            onImport={props.onImport}
+            onLoadMore={props.onLoadMore}
+            onSearch={props.onSearch}
+            projects={projects}
+            projectsPaging={projectsPaging}
+            searching={searching}
+            searchQuery={searchQuery}
+          />
+        ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.tsx
new file mode 100644 (file)
index 0000000..56f3c8e
--- /dev/null
@@ -0,0 +1,173 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Tooltip from '../../../../components/controls/Tooltip';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { ComponentQualifier } from '../../../../types/component';
+import { Paging } from '../../../../types/types';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+
+export interface GitlabProjectSelectionFormProps {
+  importingGitlabProjectId?: string;
+  loadingMore: boolean;
+  onImport: (gitlabProjectId: string) => void;
+  onLoadMore: () => void;
+  onSearch: (searchQuery: string) => void;
+  projects?: GitlabProject[];
+  projectsPaging: Paging;
+  searching: boolean;
+  searchQuery: string;
+}
+
+export default function GitlabProjectSelectionForm(props: GitlabProjectSelectionFormProps) {
+  const {
+    importingGitlabProjectId,
+    loadingMore,
+    projects = [],
+    projectsPaging,
+    searching,
+    searchQuery,
+  } = props;
+
+  if (projects.length === 0 && searchQuery.length === 0 && !searching) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.gitlab.no_projects')}
+          id="onboarding.create_project.gitlab.no_projects"
+          values={{
+            link: (
+              <Link
+                to={{
+                  pathname: '/projects/create',
+                  search: queryToSearch({ mode: CreateProjectModes.GitLab, resetPat: 1 }),
+                }}
+              >
+                {translate('onboarding.create_project.update_your_token')}
+              </Link>
+            ),
+          }}
+        />
+      </Alert>
+    );
+  }
+
+  return (
+    <>
+      <InstanceNewCodeDefinitionComplianceWarning />
+      <div className="boxed-group big-padded create-project-import">
+        <SearchBox
+          className="spacer"
+          loading={searching}
+          minLength={3}
+          onChange={props.onSearch}
+          placeholder={translate('onboarding.create_project.search_prompt')}
+        />
+
+        <hr />
+
+        {projects.length === 0 ? (
+          <div className="padded">{translate('no_results')}</div>
+        ) : (
+          <table className="data zebra zebra-hover">
+            <tbody>
+              {projects.map((project) => (
+                <tr key={project.id}>
+                  <td>
+                    <Tooltip overlay={project.slug}>
+                      <strong className="project-name display-inline-block text-ellipsis">
+                        {project.sqProjectKey ? (
+                          <Link to={getProjectUrl(project.sqProjectKey)}>
+                            <QualifierIcon
+                              className="spacer-right"
+                              qualifier={ComponentQualifier.Project}
+                            />
+                            {project.sqProjectName}
+                          </Link>
+                        ) : (
+                          project.name
+                        )}
+                      </strong>
+                    </Tooltip>
+                    <br />
+                    <Tooltip overlay={project.pathSlug}>
+                      <span className="text-muted project-path display-inline-block text-ellipsis">
+                        {project.pathName}
+                      </span>
+                    </Tooltip>
+                  </td>
+                  <td>
+                    <Link
+                      className="display-inline-flex-center big-spacer-right"
+                      to={project.url}
+                      target="_blank"
+                    >
+                      {translate('onboarding.create_project.gitlab.link')}
+                    </Link>
+                  </td>
+                  {project.sqProjectKey ? (
+                    <td>
+                      <span className="display-flex-center display-flex-justify-end already-set-up">
+                        <CheckIcon className="little-spacer-right" size={12} />
+                        {translate('onboarding.create_project.repository_imported')}
+                      </span>
+                    </td>
+                  ) : (
+                    <td className="text-right">
+                      <Button
+                        disabled={!!importingGitlabProjectId}
+                        onClick={() => props.onImport(project.id)}
+                      >
+                        {translate('onboarding.create_project.set_up')}
+                        <DeferredSpinner
+                          className="spacer-left"
+                          loading={importingGitlabProjectId === project.id}
+                        />
+                      </Button>
+                    </td>
+                  )}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+        <ListFooter
+          count={projects.length}
+          loadMore={props.onLoadMore}
+          loading={loadingMore}
+          pageSize={projectsPaging.pageSize}
+          total={projectsPaging.total}
+        />
+      </div>
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx
deleted file mode 100644 (file)
index 7e4f7af..0000000
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { getGitlabProjects, importGitlabProject } from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { GitlabProject } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
-
-interface Props {
-  canAdmin: boolean;
-  loadingBindings: boolean;
-  onProjectCreate: (projectKey: string) => void;
-  almInstances: AlmSettingsInstance[];
-  location: Location;
-  router: Router;
-}
-
-interface State {
-  importingGitlabProjectId?: string;
-  loading: boolean;
-  loadingMore: boolean;
-  projects?: GitlabProject[];
-  projectsPaging: Paging;
-  resetPat: boolean;
-  searching: boolean;
-  searchQuery: string;
-  selectedAlmInstance: AlmSettingsInstance;
-  showPersonalAccessTokenForm: boolean;
-}
-
-const GITLAB_PROJECTS_PAGESIZE = 30;
-
-export default class GitlabProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      loading: false,
-      loadingMore: false,
-      projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE },
-      resetPat: false,
-      showPersonalAccessTokenForm: true,
-      searching: false,
-      searchQuery: '',
-      selectedAlmInstance: props.almInstances[0],
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    const { almInstances } = this.props;
-    if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
-      this.setState({ selectedAlmInstance: almInstances[0] }, () => this.fetchInitialData());
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchInitialData = async () => {
-    const { showPersonalAccessTokenForm } = this.state;
-
-    if (!showPersonalAccessTokenForm) {
-      this.setState({ loading: true });
-      const result = await this.fetchProjects();
-      if (this.mounted && result) {
-        const { projects, projectsPaging } = result;
-
-        this.setState({
-          loading: false,
-          projects,
-          projectsPaging,
-        });
-      } else {
-        this.setState({
-          loading: false,
-        });
-      }
-    }
-  };
-
-  handleError = () => {
-    if (this.mounted) {
-      this.setState({ resetPat: true, showPersonalAccessTokenForm: true });
-    }
-
-    return undefined;
-  };
-
-  fetchProjects = async (pageIndex = 1, query?: string) => {
-    const { selectedAlmInstance } = this.state;
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
-
-    try {
-      return await getGitlabProjects({
-        almSetting: selectedAlmInstance.key,
-        page: pageIndex,
-        pageSize: GITLAB_PROJECTS_PAGESIZE,
-        query,
-      });
-    } catch (_) {
-      return this.handleError();
-    }
-  };
-
-  doImport = async (gitlabProjectId: string) => {
-    const { selectedAlmInstance } = this.state;
-
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
-
-    try {
-      return await importGitlabProject({
-        almSetting: selectedAlmInstance.key,
-        gitlabProjectId,
-      });
-    } catch (_) {
-      return this.handleError();
-    }
-  };
-
-  handleImport = async (gitlabProjectId: string) => {
-    this.setState({ importingGitlabProjectId: gitlabProjectId });
-
-    const result = await this.doImport(gitlabProjectId);
-
-    if (this.mounted) {
-      this.setState({ importingGitlabProjectId: undefined });
-
-      if (result) {
-        this.props.onProjectCreate(result.project.key);
-      }
-    }
-  };
-
-  handleLoadMore = async () => {
-    this.setState({ loadingMore: true });
-
-    const {
-      projectsPaging: { pageIndex },
-      searchQuery,
-    } = this.state;
-
-    const result = await this.fetchProjects(pageIndex + 1, searchQuery);
-    if (this.mounted) {
-      this.setState(({ projects = [], projectsPaging }) => ({
-        loadingMore: false,
-        projects: result ? [...projects, ...result.projects] : projects,
-        projectsPaging: result ? result.projectsPaging : projectsPaging,
-      }));
-    }
-  };
-
-  handleSearch = async (searchQuery: string) => {
-    this.setState({ searching: true, searchQuery });
-
-    const result = await this.fetchProjects(1, searchQuery);
-    if (this.mounted) {
-      this.setState(({ projects, projectsPaging }) => ({
-        searching: false,
-        projects: result ? result.projects : projects,
-        projectsPaging: result ? result.projectsPaging : projectsPaging,
-      }));
-    }
-  };
-
-  cleanUrl = () => {
-    const { location, router } = this.props;
-    delete location.query.resetPat;
-    router.replace(location);
-  };
-
-  handlePersonalAccessTokenCreated = async () => {
-    this.setState({ showPersonalAccessTokenForm: false, resetPat: false });
-    this.cleanUrl();
-    await this.fetchInitialData();
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState({
-      selectedAlmInstance: instance,
-      showPersonalAccessTokenForm: true,
-      projects: undefined,
-      resetPat: false,
-      searchQuery: '',
-    });
-  };
-
-  render() {
-    const { loadingBindings, location, almInstances, canAdmin } = this.props;
-    const {
-      importingGitlabProjectId,
-      loading,
-      loadingMore,
-      projects,
-      projectsPaging,
-      resetPat,
-      searching,
-      searchQuery,
-      selectedAlmInstance,
-      showPersonalAccessTokenForm,
-    } = this.state;
-
-    return (
-      <GitlabProjectCreateRenderer
-        canAdmin={canAdmin}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        importingGitlabProjectId={importingGitlabProjectId}
-        loading={loading || loadingBindings}
-        loadingMore={loadingMore}
-        onImport={this.handleImport}
-        onLoadMore={this.handleLoadMore}
-        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
-        onSearch={this.handleSearch}
-        projects={projects}
-        projectsPaging={projectsPaging}
-        resetPat={resetPat || Boolean(location.query.resetPat)}
-        searching={searching}
-        searchQuery={searchQuery}
-        showPersonalAccessTokenForm={
-          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
-        }
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx
deleted file mode 100644 (file)
index a6f9785..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { GitlabProject } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface GitlabProjectCreateRendererProps {
-  canAdmin?: boolean;
-  importingGitlabProjectId?: string;
-  loading: boolean;
-  loadingMore: boolean;
-  onImport: (gitlabProjectId: string) => void;
-  onLoadMore: () => void;
-  onPersonalAccessTokenCreated: () => void;
-  onSearch: (searchQuery: string) => void;
-  projects?: GitlabProject[];
-  projectsPaging: Paging;
-  resetPat: boolean;
-  searching: boolean;
-  searchQuery: string;
-  almInstances?: AlmSettingsInstance[];
-  selectedAlmInstance?: AlmSettingsInstance;
-  showPersonalAccessTokenForm?: boolean;
-  onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-}
-
-export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
-  const {
-    canAdmin,
-    importingGitlabProjectId,
-    loading,
-    loadingMore,
-    projects,
-    projectsPaging,
-    resetPat,
-    searching,
-    searchQuery,
-    selectedAlmInstance,
-    almInstances,
-    showPersonalAccessTokenForm,
-  } = props;
-
-  return (
-    <>
-      <CreateProjectPageHeader
-        title={
-          <span className="text-middle">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height="24"
-              src={`${getBaseUrl()}/images/alm/gitlab.svg`}
-            />
-            {translate('onboarding.create_project.gitlab.title')}
-          </span>
-        }
-      />
-
-      <AlmSettingsInstanceDropdown
-        almKey={AlmKeys.GitLab}
-        almInstances={almInstances}
-        selectedAlmInstance={selectedAlmInstance}
-        onChangeConfig={props.onSelectedAlmInstanceChange}
-      />
-
-      {loading && <i className="spinner" />}
-
-      {!loading && !selectedAlmInstance && (
-        <WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />
-      )}
-
-      {!loading &&
-        selectedAlmInstance &&
-        (showPersonalAccessTokenForm ? (
-          <PersonalAccessTokenForm
-            almSetting={selectedAlmInstance}
-            resetPat={resetPat}
-            onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
-          />
-        ) : (
-          <GitlabProjectSelectionForm
-            importingGitlabProjectId={importingGitlabProjectId}
-            loadingMore={loadingMore}
-            onImport={props.onImport}
-            onLoadMore={props.onLoadMore}
-            onSearch={props.onSearch}
-            projects={projects}
-            projectsPaging={projectsPaging}
-            searching={searching}
-            searchQuery={searchQuery}
-          />
-        ))}
-    </>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectSelectionForm.tsx b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectSelectionForm.tsx
deleted file mode 100644 (file)
index aad16aa..0000000
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import ListFooter from '../../../components/controls/ListFooter';
-import SearchBox from '../../../components/controls/SearchBox';
-import Tooltip from '../../../components/controls/Tooltip';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { GitlabProject } from '../../../types/alm-integration';
-import { ComponentQualifier } from '../../../types/component';
-import { Paging } from '../../../types/types';
-import { CreateProjectModes } from './types';
-
-export interface GitlabProjectSelectionFormProps {
-  importingGitlabProjectId?: string;
-  loadingMore: boolean;
-  onImport: (gitlabProjectId: string) => void;
-  onLoadMore: () => void;
-  onSearch: (searchQuery: string) => void;
-  projects?: GitlabProject[];
-  projectsPaging: Paging;
-  searching: boolean;
-  searchQuery: string;
-}
-
-export default function GitlabProjectSelectionForm(props: GitlabProjectSelectionFormProps) {
-  const {
-    importingGitlabProjectId,
-    loadingMore,
-    projects = [],
-    projectsPaging,
-    searching,
-    searchQuery,
-  } = props;
-
-  if (projects.length === 0 && searchQuery.length === 0 && !searching) {
-    return (
-      <Alert className="spacer-top" variant="warning">
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.gitlab.no_projects')}
-          id="onboarding.create_project.gitlab.no_projects"
-          values={{
-            link: (
-              <Link
-                to={{
-                  pathname: '/projects/create',
-                  search: queryToSearch({ mode: CreateProjectModes.GitLab, resetPat: 1 }),
-                }}
-              >
-                {translate('onboarding.create_project.update_your_token')}
-              </Link>
-            ),
-          }}
-        />
-      </Alert>
-    );
-  }
-
-  return (
-    <div className="boxed-group big-padded create-project-import">
-      <SearchBox
-        className="spacer"
-        loading={searching}
-        minLength={3}
-        onChange={props.onSearch}
-        placeholder={translate('onboarding.create_project.search_prompt')}
-      />
-
-      <hr />
-
-      {projects.length === 0 ? (
-        <div className="padded">{translate('no_results')}</div>
-      ) : (
-        <table className="data zebra zebra-hover">
-          <tbody>
-            {projects.map((project) => (
-              <tr key={project.id}>
-                <td>
-                  <Tooltip overlay={project.slug}>
-                    <strong className="project-name display-inline-block text-ellipsis">
-                      {project.sqProjectKey ? (
-                        <Link to={getProjectUrl(project.sqProjectKey)}>
-                          <QualifierIcon
-                            className="spacer-right"
-                            qualifier={ComponentQualifier.Project}
-                          />
-                          {project.sqProjectName}
-                        </Link>
-                      ) : (
-                        project.name
-                      )}
-                    </strong>
-                  </Tooltip>
-                  <br />
-                  <Tooltip overlay={project.pathSlug}>
-                    <span className="text-muted project-path display-inline-block text-ellipsis">
-                      {project.pathName}
-                    </span>
-                  </Tooltip>
-                </td>
-                <td>
-                  <Link
-                    className="display-inline-flex-center big-spacer-right"
-                    to={project.url}
-                    target="_blank"
-                  >
-                    {translate('onboarding.create_project.gitlab.link')}
-                  </Link>
-                </td>
-                {project.sqProjectKey ? (
-                  <td>
-                    <span className="display-flex-center display-flex-justify-end already-set-up">
-                      <CheckIcon className="little-spacer-right" size={12} />
-                      {translate('onboarding.create_project.repository_imported')}
-                    </span>
-                  </td>
-                ) : (
-                  <td className="text-right">
-                    <Button
-                      disabled={!!importingGitlabProjectId}
-                      onClick={() => props.onImport(project.id)}
-                    >
-                      {translate('onboarding.create_project.set_up')}
-                      {importingGitlabProjectId === project.id && (
-                        <DeferredSpinner className="spacer-left" />
-                      )}
-                    </Button>
-                  </td>
-                )}
-              </tr>
-            ))}
-          </tbody>
-        </table>
-      )}
-      <ListFooter
-        count={projects.length}
-        loadMore={props.onLoadMore}
-        loading={loadingMore}
-        pageSize={projectsPaging.pageSize}
-        total={projectsPaging.total}
-      />
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css
deleted file mode 100644 (file)
index 4ca8f2a..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.
- */
-.manual-project-create {
-  max-width: 700px;
-}
-
-.manual-project-create .button {
-  margin-top: var(--gridSize);
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx
deleted file mode 100644 (file)
index 44b290c..0000000
+++ /dev/null
@@ -1,329 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { debounce, isEmpty } from 'lodash';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { createProject, doesComponentExists } from '../../../api/components';
-import { getValue } from '../../../api/settings';
-import DocLink from '../../../components/common/DocLink';
-import ProjectKeyInput from '../../../components/common/ProjectKeyInput';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { SubmitButton } from '../../../components/controls/buttons';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
-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 CreateProjectPageHeader from './CreateProjectPageHeader';
-import './ManualProjectCreate.css';
-import { PROJECT_NAME_MAX_LEN } from './constants';
-
-interface Props {
-  branchesEnabled: boolean;
-  onProjectCreate: (projectKey: string) => void;
-}
-
-interface State {
-  projectName: string;
-  projectNameError?: string;
-  projectNameTouched: boolean;
-  projectKey: string;
-  projectKeyError?: string;
-  projectKeyTouched: boolean;
-  validatingProjectKey: boolean;
-  mainBranchName: string;
-  mainBranchNameError?: string;
-  mainBranchNameTouched: boolean;
-  submitting: boolean;
-}
-
-const DEBOUNCE_DELAY = 250;
-
-type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;
-
-export default class ManualProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      projectKey: '',
-      projectName: '',
-      submitting: false,
-      projectKeyTouched: false,
-      projectNameTouched: false,
-      mainBranchName: 'main',
-      mainBranchNameTouched: false,
-      validatingProjectKey: false,
-    };
-    this.checkFreeKey = debounce(this.checkFreeKey, DEBOUNCE_DELAY);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchMainBranchName();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchMainBranchName = async () => {
-    const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });
-
-    if (this.mounted && mainBranchName.value !== undefined) {
-      this.setState({ mainBranchName: mainBranchName.value });
-    }
-  };
-
-  checkFreeKey = (key: string) => {
-    this.setState({ validatingProjectKey: true });
-
-    doesComponentExists({ component: key })
-      .then((alreadyExist) => {
-        if (this.mounted && key === this.state.projectKey) {
-          this.setState({
-            projectKeyError: alreadyExist
-              ? translate('onboarding.create_project.project_key.taken')
-              : undefined,
-            validatingProjectKey: false,
-          });
-        }
-      })
-      .catch(() => {
-        if (this.mounted && key === this.state.projectKey) {
-          this.setState({ projectKeyError: undefined, validatingProjectKey: false });
-        }
-      });
-  };
-
-  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)
-    );
-  }
-
-  handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
-    event.preventDefault();
-    const { projectKey, projectName, mainBranchName } = this.state;
-    if (this.canSubmit(this.state)) {
-      this.setState({ submitting: true });
-      createProject({
-        project: projectKey,
-        name: (projectName || projectKey).trim(),
-        mainBranch: mainBranchName,
-      }).then(
-        ({ project }) => this.props.onProjectCreate(project.key),
-        () => {
-          if (this.mounted) {
-            this.setState({ submitting: false });
-          }
-        }
-      );
-    }
-  };
-
-  handleProjectKeyChange = (projectKey: string, fromUI = false) => {
-    const projectKeyError = this.validateKey(projectKey);
-
-    this.setState({
-      projectKey,
-      projectKeyError,
-      projectKeyTouched: fromUI,
-    });
-
-    if (projectKeyError === undefined) {
-      this.checkFreeKey(projectKey);
-    }
-  };
-
-  handleProjectNameChange = (projectName: string, fromUI = false) => {
-    this.setState(
-      {
-        projectName,
-        projectNameError: this.validateName(projectName),
-        projectNameTouched: fromUI,
-      },
-      () => {
-        if (!this.state.projectKeyTouched) {
-          const sanitizedProjectKey = this.state.projectName
-            .trim()
-            .replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
-          this.handleProjectKeyChange(sanitizedProjectKey);
-        }
-      }
-    );
-  };
-
-  handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
-    this.setState({
-      mainBranchName,
-      mainBranchNameError: this.validateMainBranchName(mainBranchName),
-      mainBranchNameTouched: fromUI,
-    });
-  };
-
-  validateKey = (projectKey: string) => {
-    const result = validateProjectKey(projectKey);
-    return result === ProjectKeyValidationResult.Valid
-      ? undefined
-      : translate('onboarding.create_project.project_key.error', result);
-  };
-
-  validateName = (projectName: string) => {
-    if (isEmpty(projectName)) {
-      return translate('onboarding.create_project.display_name.error.empty');
-    }
-    return undefined;
-  };
-
-  validateMainBranchName = (mainBranchName: string) => {
-    if (isEmpty(mainBranchName)) {
-      return translate('onboarding.create_project.main_branch_name.error.empty');
-    }
-    return undefined;
-  };
-
-  render() {
-    const {
-      projectKey,
-      projectKeyError,
-      projectKeyTouched,
-      projectName,
-      projectNameError,
-      projectNameTouched,
-      validatingProjectKey,
-      mainBranchName,
-      mainBranchNameError,
-      mainBranchNameTouched,
-      submitting,
-    } = this.state;
-    const { branchesEnabled } = this.props;
-
-    const touched = Boolean(projectKeyTouched || projectNameTouched);
-    const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
-    const projectNameIsValid = projectNameTouched && projectNameError === undefined;
-    const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
-    const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;
-
-    return (
-      <>
-        <CreateProjectPageHeader title={translate('onboarding.create_project.setup_manually')} />
-
-        <div className="create-project-manual">
-          <div className="flex-1 huge-spacer-right">
-            <form className="manual-project-create" onSubmit={this.handleFormSubmit}>
-              <MandatoryFieldsExplanation className="big-spacer-bottom" />
-
-              <ValidationInput
-                className="form-field"
-                description={translate('onboarding.create_project.display_name.description')}
-                error={projectNameError}
-                labelHtmlFor="project-name"
-                isInvalid={projectNameIsInvalid}
-                isValid={projectNameIsValid}
-                label={translate('onboarding.create_project.display_name')}
-                required={true}
-              >
-                <input
-                  className={classNames('input-super-large', {
-                    'is-invalid': projectNameIsInvalid,
-                    'is-valid': projectNameIsValid,
-                  })}
-                  id="project-name"
-                  maxLength={PROJECT_NAME_MAX_LEN}
-                  minLength={1}
-                  onChange={(e) => this.handleProjectNameChange(e.currentTarget.value, true)}
-                  type="text"
-                  value={projectName}
-                  autoFocus={true}
-                />
-              </ValidationInput>
-              <ProjectKeyInput
-                error={projectKeyError}
-                label={translate('onboarding.create_project.project_key')}
-                onProjectKeyChange={(e) => this.handleProjectKeyChange(e.currentTarget.value, true)}
-                projectKey={projectKey}
-                touched={touched}
-                validating={validatingProjectKey}
-              />
-
-              <ValidationInput
-                className="form-field"
-                description={
-                  <FormattedMessage
-                    id="onboarding.create_project.main_branch_name.description"
-                    defaultMessage={translate(
-                      'onboarding.create_project.main_branch_name.description'
-                    )}
-                    values={{
-                      learn_more: (
-                        <DocLink to="/analyzing-source-code/branches/branch-analysis">
-                          {translate('learn_more')}
-                        </DocLink>
-                      ),
-                    }}
-                  />
-                }
-                error={mainBranchNameError}
-                labelHtmlFor="main-branch-name"
-                isInvalid={mainBranchNameIsInvalid}
-                isValid={mainBranchNameIsValid}
-                label={translate('onboarding.create_project.main_branch_name')}
-                required={true}
-              >
-                <input
-                  id="main-branch-name"
-                  className={classNames('input-super-large', {
-                    'is-invalid': mainBranchNameIsInvalid,
-                    'is-valid': mainBranchNameIsValid,
-                  })}
-                  minLength={1}
-                  onChange={(e) => this.handleBranchNameChange(e.currentTarget.value, true)}
-                  type="text"
-                  value={mainBranchName}
-                />
-              </ValidationInput>
-
-              <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
-                {translate('set_up')}
-              </SubmitButton>
-              <DeferredSpinner className="spacer-left" loading={submitting} />
-            </form>
-
-            {branchesEnabled && (
-              <Alert variant="info" display="inline" className="big-spacer-top">
-                {translate('onboarding.create_project.pr_decoration.information')}
-              </Alert>
-            )}
-          </div>
-        </div>
-      </>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx
deleted file mode 100644 (file)
index 589b8c7..0000000
+++ /dev/null
@@ -1,428 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import {
-  checkPersonalAccessTokenIsValid,
-  setAlmPersonalAccessToken,
-} from '../../../api/alm-integrations';
-import { SubmitButton } from '../../../components/controls/buttons';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { tokenExistedBefore } from './utils';
-
-interface Props {
-  almSetting: AlmSettingsInstance;
-  resetPat: boolean;
-  onPersonalAccessTokenCreated: () => void;
-}
-
-interface State {
-  validationFailed: boolean;
-  validationErrorMessage?: string;
-  touched: boolean;
-  password: string;
-  username?: string;
-  submitting: boolean;
-  checkingPat: boolean;
-  firstConnection: boolean;
-}
-
-function getPatUrl(alm: AlmKeys, url = '') {
-  if (alm === AlmKeys.BitbucketServer) {
-    return `${url.replace(/\/$/, '')}/account`;
-  } else if (alm === AlmKeys.BitbucketCloud) {
-    return 'https://bitbucket.org/account/settings/app-passwords/new';
-  } else if (alm === AlmKeys.GitLab) {
-    return 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html';
-  }
-
-  return '';
-}
-
-export default class PersonalAccessTokenForm extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      checkingPat: false,
-      touched: false,
-      password: '',
-      submitting: false,
-      validationFailed: false,
-      firstConnection: false,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.checkPATAndUpdateView();
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (this.props.almSetting !== prevProps.almSetting) {
-      this.checkPATAndUpdateView();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  checkPATAndUpdateView = async () => {
-    const {
-      almSetting: { key },
-      resetPat,
-    } = this.props;
-
-    // We don't need to check PAT if we want to reset
-    if (!resetPat) {
-      this.setState({ checkingPat: true });
-      const { patIsValid, error } = await checkPersonalAccessTokenIsValid(key)
-        .then(({ status, error }) => ({ patIsValid: status, error }))
-        .catch(() => ({ patIsValid: status, error: translate('default_error_message') }));
-      if (patIsValid) {
-        this.props.onPersonalAccessTokenCreated();
-      }
-      if (this.mounted) {
-        // This is the initial message when no token was provided
-        if (tokenExistedBefore(error)) {
-          this.setState({
-            checkingPat: false,
-            firstConnection: true,
-          });
-        } else {
-          this.setState({
-            checkingPat: false,
-            validationFailed: true,
-            validationErrorMessage: error,
-          });
-        }
-      }
-    }
-  };
-
-  handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    this.setState({
-      touched: true,
-      username: event.target.value,
-    });
-  };
-
-  handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    this.setState({
-      touched: true,
-      password: event.target.value,
-    });
-  };
-
-  handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
-    const { password, username } = this.state;
-    const {
-      almSetting: { key },
-    } = this.props;
-
-    e.preventDefault();
-    if (password) {
-      this.setState({ submitting: true });
-
-      await setAlmPersonalAccessToken(key, password, username).catch(() => {
-        /* Set will not check pat validity. We need to check again so we will catch issue after */
-      });
-
-      const { status, error } = await checkPersonalAccessTokenIsValid(key)
-        .then(({ status, error }) => ({ status, error }))
-        .catch(() => ({ status: false, error: translate('default_error_message') }));
-
-      if (this.mounted && status) {
-        // Let's reset status,
-        this.setState({
-          checkingPat: false,
-          touched: false,
-          password: '',
-          submitting: false,
-          username: '',
-          validationFailed: false,
-        });
-        this.props.onPersonalAccessTokenCreated();
-      } else if (this.mounted) {
-        this.setState({
-          submitting: false,
-          touched: false,
-          validationFailed: true,
-          validationErrorMessage: error,
-        });
-      }
-    }
-  };
-
-  renderHelpBox(suffixTranslationKey: string) {
-    const {
-      almSetting: { alm, url },
-    } = this.props;
-
-    return (
-      <Alert className="big-spacer-left width-50" display="block" variant="info">
-        {alm === AlmKeys.BitbucketCloud && (
-          <>
-            <h3>
-              {translate(
-                'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.title'
-              )}
-            </h3>
-            <p className="big-spacer-top big-spacer-bottom">
-              {translate('onboarding.create_project.pat_help.instructions_username.bitbucketcloud')}
-            </p>
-
-            <div className="text-middle big-spacer-bottom">
-              <img
-                alt="" // Should be ignored by screen readers
-                className="spacer-right"
-                height="16"
-                src={`${getBaseUrl()}/images/alm/${AlmKeys.BitbucketServer}.svg`}
-              />
-              <a
-                href="https://bitbucket.org/account/settings/"
-                rel="noopener noreferrer"
-                target="_blank"
-              >
-                {translate(
-                  'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.link'
-                )}
-              </a>
-            </div>
-          </>
-        )}
-
-        <h3>{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}</h3>
-
-        <p className="big-spacer-top big-spacer-bottom">
-          {alm === AlmKeys.BitbucketServer ? (
-            <FormattedMessage
-              id="onboarding.create_project.pat_help.instructions"
-              defaultMessage={translate(
-                `onboarding.create_project.pat_help.bitbucket.instructions`
-              )}
-              values={{
-                menu: (
-                  <strong>
-                    {translate('onboarding.create_project.pat_help.bitbucket.instructions.menu')}
-                  </strong>
-                ),
-                button: (
-                  <strong>
-                    {translate('onboarding.create_project.pat_help.bitbucket.instructions.button')}
-                  </strong>
-                ),
-              }}
-            />
-          ) : (
-            <FormattedMessage
-              id="onboarding.create_project.pat_help.instructions"
-              defaultMessage={translate(
-                `onboarding.create_project.pat_help${suffixTranslationKey}.instructions`
-              )}
-              values={{
-                alm: translate('onboarding.alm', alm),
-              }}
-            />
-          )}
-        </p>
-
-        {(url || alm === AlmKeys.BitbucketCloud) && (
-          <div className="text-middle">
-            <img
-              alt="" // Should be ignored by screen readers
-              className="spacer-right"
-              height="16"
-              src={`${getBaseUrl()}/images/alm/${
-                alm === AlmKeys.BitbucketCloud ? AlmKeys.BitbucketServer : alm
-              }.svg`}
-            />
-            <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank">
-              {translate(`onboarding.create_project.pat_help${suffixTranslationKey}.link`)}
-            </a>
-          </div>
-        )}
-
-        <p className="big-spacer-top big-spacer-bottom">
-          {translate('onboarding.create_project.pat_help.instructions2', alm)}
-        </p>
-
-        <ul>
-          {alm === AlmKeys.BitbucketServer && (
-            <li>
-              <FormattedMessage
-                defaultMessage={translate(
-                  'onboarding.create_project.pat_help.bbs_permission_projects'
-                )}
-                id="onboarding.create_project.pat_help.bbs_permission_projects"
-                values={{
-                  perm: (
-                    <strong>
-                      {translate('onboarding.create_project.pat_help.read_permission')}
-                    </strong>
-                  ),
-                }}
-              />
-            </li>
-          )}
-          {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && (
-            <li>
-              <FormattedMessage
-                defaultMessage={translate(
-                  'onboarding.create_project.pat_help.bbs_permission_repos'
-                )}
-                id="onboarding.create_project.pat_help.bbs_permission_repos"
-                values={{
-                  perm: (
-                    <strong>
-                      {translate('onboarding.create_project.pat_help.read_permission')}
-                    </strong>
-                  ),
-                }}
-              />
-            </li>
-          )}
-
-          {alm === AlmKeys.GitLab && (
-            <li className="spacer-bottom">
-              <strong>
-                {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')}
-              </strong>
-            </li>
-          )}
-        </ul>
-      </Alert>
-    );
-  }
-
-  render() {
-    const {
-      almSetting: { alm },
-    } = this.props;
-    const {
-      checkingPat,
-      submitting,
-      touched,
-      password,
-      username,
-      validationFailed,
-      validationErrorMessage,
-      firstConnection,
-    } = this.state;
-
-    if (checkingPat) {
-      return <DeferredSpinner className="spacer-left" loading={true} />;
-    }
-
-    const suffixTranslationKey = alm === AlmKeys.BitbucketCloud ? '.bitbucketcloud' : '';
-
-    const isInvalid = validationFailed && !touched;
-    const canSubmit = Boolean(password) && (alm !== AlmKeys.BitbucketCloud || Boolean(username));
-    const submitButtonDiabled = isInvalid || submitting || !canSubmit;
-
-    const errorMessage =
-      validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm);
-
-    return (
-      <div className="display-flex-start">
-        <form className="width-50" onSubmit={this.handleSubmit}>
-          <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2>
-          <p className="big-spacer-top big-spacer-bottom">
-            {translate('onboarding.create_project.pat_form.help', alm)}
-          </p>
-
-          {!firstConnection && (
-            <Alert className="big-spacer-right" variant="warning">
-              <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
-              <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
-            </Alert>
-          )}
-
-          {alm === AlmKeys.BitbucketCloud && (
-            <ValidationInput
-              error={undefined}
-              labelHtmlFor="enter_username_validation"
-              isInvalid={false}
-              isValid={false}
-              label={translate('onboarding.create_project.enter_username')}
-              required={true}
-            >
-              <input
-                autoFocus={true}
-                className={classNames('input-super-large', {
-                  'is-invalid': isInvalid,
-                })}
-                id="enter_username_validation"
-                minLength={1}
-                name="username"
-                value={username}
-                onChange={this.handleUsernameChange}
-                type="text"
-              />
-            </ValidationInput>
-          )}
-
-          <ValidationInput
-            error={errorMessage}
-            labelHtmlFor="personal_access_token_validation"
-            isInvalid={false}
-            isValid={false}
-            label={translate(`onboarding.create_project.enter_pat${suffixTranslationKey}`)}
-            required={true}
-          >
-            <input
-              autoFocus={alm !== AlmKeys.BitbucketCloud}
-              className={classNames('input-super-large', {
-                'is-invalid': isInvalid,
-              })}
-              id="personal_access_token_validation"
-              minLength={1}
-              value={password}
-              onChange={this.handlePasswordChange}
-              type="text"
-            />
-          </ValidationInput>
-
-          <ValidationInput
-            error={errorMessage}
-            labelHtmlFor="personal_access_token_submit"
-            isInvalid={isInvalid}
-            isValid={false}
-            label={null}
-          >
-            <SubmitButton disabled={submitButtonDiabled}>{translate('save')}</SubmitButton>
-            <DeferredSpinner className="spacer-left" loading={submitting} />
-          </ValidationInput>
-        </form>
-
-        {this.renderHelpBox(suffixTranslationKey)}
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/create/project/WrongBindingCountAlert.tsx b/server/sonar-web/src/main/js/apps/create/project/WrongBindingCountAlert.tsx
deleted file mode 100644 (file)
index 7588b6c..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Alert } from '../../../components/ui/Alert';
-import { translate } from '../../../helpers/l10n';
-import { getGlobalSettingsUrl } from '../../../helpers/urls';
-import { AlmKeys } from '../../../types/alm-settings';
-import { ALM_INTEGRATION_CATEGORY } from '../../settings/constants';
-
-export interface WrongBindingCountAlertProps {
-  alm: AlmKeys;
-  canAdmin: boolean;
-}
-
-export default function WrongBindingCountAlert(props: WrongBindingCountAlertProps) {
-  const { alm, canAdmin } = props;
-
-  return (
-    <Alert variant="error">
-      {canAdmin ? (
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.wrong_binding_count.admin')}
-          id="onboarding.create_project.wrong_binding_count.admin"
-          values={{
-            alm: translate('onboarding.alm', alm),
-            url: (
-              <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
-                {translate('settings.page')}
-              </Link>
-            ),
-          }}
-        />
-      ) : (
-        <FormattedMessage
-          defaultMessage={translate('onboarding.create_project.wrong_binding_count')}
-          id="onboarding.create_project.wrong_binding_count"
-          values={{
-            alm: translate('onboarding.alm', alm),
-          }}
-        />
-      )}
-    </Alert>
-  );
-}
index 0ace653f9fe357d8fcfa15ad7803b5cb19adb4df..a3e4a6b838f4078e071bf2a1862742877fa73169 100644 (file)
@@ -26,6 +26,7 @@ import { byLabelText, byRole, byText } from 'testing-library-selector';
 import { searchAzureRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
 
@@ -34,6 +35,7 @@ jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
 let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
 
 const ui = {
   azureCreateProjectButton: byText('onboarding.create_project.select_method.azure'),
@@ -46,12 +48,14 @@ const ui = {
 beforeAll(() => {
   almIntegrationHandler = new AlmIntegrationsServiceMock();
   almSettingsHandler = new AlmSettingsServiceMock();
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
   almSettingsHandler.reset();
+  newCodePeriodHandler.reset();
 });
 
 it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
index e8d1fd22e8147aadcc01dceb05cdb38bdb5561f8..6c66849c475834b1b59e2d8c2b13e84110d941c2 100644 (file)
@@ -26,6 +26,7 @@ import { byLabelText, byRole, byText } from 'testing-library-selector';
 import { searchForBitbucketServerRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
 
@@ -34,6 +35,7 @@ jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
 let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
 
 const ui = {
   bitbucketServerCreateProjectButton: byText('onboarding.create_project.select_method.bitbucket'),
@@ -46,12 +48,14 @@ const ui = {
 beforeAll(() => {
   almIntegrationHandler = new AlmIntegrationsServiceMock();
   almSettingsHandler = new AlmSettingsServiceMock();
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
   almSettingsHandler.reset();
+  newCodePeriodHandler.reset();
 });
 
 it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
index c82b35c2b86cc2cc5827c004c9f82aa201604e3a..c301a25e9df9226ba41251230c0186c19ec9bdd8 100644 (file)
@@ -26,6 +26,7 @@ import { byLabelText, byRole, byText } from 'testing-library-selector';
 import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
 
@@ -34,6 +35,7 @@ jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
 let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
 
 const ui = {
   bitbucketCloudCreateProjectButton: byText(
@@ -48,12 +50,14 @@ const ui = {
 beforeAll(() => {
   almIntegrationHandler = new AlmIntegrationsServiceMock();
   almSettingsHandler = new AlmSettingsServiceMock();
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
   almSettingsHandler.reset();
+  newCodePeriodHandler.reset();
 });
 
 it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
index 9685d20f82262c40963a0249c9d532910067be5b..481a7db84cadd6f7cf8ab51bece6d010ed3b23fd 100644 (file)
@@ -26,6 +26,7 @@ import { byLabelText, byText } from 'testing-library-selector';
 import { getGithubRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import CreateProjectPage from '../CreateProjectPage';
 
@@ -36,6 +37,7 @@ const original = window.location;
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
 let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
 
 const ui = {
   githubCreateProjectButton: byText('onboarding.create_project.select_method.github'),
@@ -50,12 +52,14 @@ beforeAll(() => {
   });
   almIntegrationHandler = new AlmIntegrationsServiceMock();
   almSettingsHandler = new AlmSettingsServiceMock();
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
   almSettingsHandler.reset();
+  newCodePeriodHandler.reset();
 });
 
 afterAll(() => {
index 88644cb4e44fc9b7878146fe891c3853b44040f3..0e71dbb86ff4176151cb36a266d136598e879576 100644 (file)
@@ -25,7 +25,10 @@ import { byLabelText, byRole, byText } from 'testing-library-selector';
 import { getGitlabProjects } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
+import { mockNewCodePeriod } from '../../../../helpers/mocks/new-code-period';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
+import { NewCodePeriodSettingType } from '../../../../types/types';
 import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
 
 jest.mock('../../../../api/alm-integrations');
@@ -33,6 +36,7 @@ jest.mock('../../../../api/alm-settings');
 
 let almIntegrationHandler: AlmIntegrationsServiceMock;
 let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
 
 const ui = {
   gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
@@ -46,12 +50,14 @@ const ui = {
 beforeAll(() => {
   almIntegrationHandler = new AlmIntegrationsServiceMock();
   almSettingsHandler = new AlmSettingsServiceMock();
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
 });
 
 beforeEach(() => {
   jest.clearAllMocks();
   almIntegrationHandler.reset();
   almSettingsHandler.reset();
+  newCodePeriodHandler.reset();
 });
 
 it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
@@ -178,6 +184,24 @@ it('should show no result message when there are no projects', async () => {
   );
 });
 
+it('should display a warning if the instance default new code definition is not CaYC compliant', async () => {
+  const user = userEvent.setup();
+  newCodePeriodHandler.setNewCodePeriod(
+    mockNewCodePeriod({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '91' })
+  );
+  renderCreateProject();
+  await act(async () => {
+    await user.click(ui.gitlabCreateProjectButton.get());
+    await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);
+  });
+
+  expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
+  expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
+  expect(screen.getByRole('alert')).toHaveTextContent(
+    'onboarding.create_project.new_code_option.warning.title'
+  );
+});
+
 function renderCreateProject(props: Partial<CreateProjectPageProps> = {}) {
   renderApp('project/create', <CreateProjectPage {...props} />);
 }
index 1ac0b03a7f6d604c0019d57c0191c07fea12a240..98a05291a2ad99eece98da71b709432b927c0f19 100644 (file)
@@ -21,8 +21,9 @@ import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { createProject, doesComponentExists } from '../../../../api/components';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
 import { renderComponent } from '../../../../helpers/testReactTestingUtils';
-import ManualProjectCreate from '../ManualProjectCreate';
+import ManualProjectCreate from '../manual/ManualProjectCreate';
 
 jest.mock('../../../../api/components', () => ({
   createProject: jest.fn().mockResolvedValue({ project: { key: 'bar', name: 'Bar' } }),
@@ -35,8 +36,15 @@ jest.mock('../../../../api/settings', () => ({
   getValue: jest.fn().mockResolvedValue({ value: 'main' }),
 }));
 
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
+
+beforeAll(() => {
+  newCodePeriodHandler = new NewCodePeriodsServiceMock();
+});
+
 beforeEach(() => {
   jest.clearAllMocks();
+  newCodePeriodHandler.reset();
 });
 
 it('should show branch information', async () => {
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx b/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx
new file mode 100644 (file)
index 0000000..b7c5c13
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import AlmSettingsInstanceSelector from '../../../../components/devops-platform/AlmSettingsInstanceSelector';
+import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+
+export interface AlmSettingsInstanceDropdownProps {
+  almKey: AlmKeys;
+  almInstances?: AlmSettingsInstance[];
+  selectedAlmInstance?: AlmSettingsInstance;
+  onChangeConfig: (instance: AlmSettingsInstance) => void;
+}
+
+const MIN_SIZE_INSTANCES = 2;
+
+export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) {
+  const { almKey, almInstances, selectedAlmInstance } = props;
+  if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) {
+    return null;
+  }
+
+  const almKeyTranslation = hasMessage(`alm.${almKey}.long`)
+    ? `alm.${almKey}.long`
+    : `alm.${almKey}`;
+
+  return (
+    <div className="display-flex-column huge-spacer-bottom">
+      <label htmlFor="alm-config-selector" className="spacer-bottom">
+        {translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
+      </label>
+      <AlmSettingsInstanceSelector
+        instances={almInstances}
+        onChange={props.onChangeConfig}
+        initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
+        classNames="abs-width-400"
+        inputId="alm-config-selector"
+      />
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/CreateProjectPageHeader.tsx b/server/sonar-web/src/main/js/apps/create/project/components/CreateProjectPageHeader.tsx
new file mode 100644 (file)
index 0000000..f5d4517
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+
+export interface CreateProjectPageHeaderProps {
+  additionalActions?: React.ReactNode;
+  title: React.ReactNode;
+}
+
+export default function CreateProjectPageHeader(props: CreateProjectPageHeaderProps) {
+  const { additionalActions, title } = props;
+
+  return (
+    <header className="huge-spacer-bottom bordered-bottom overflow-hidden">
+      <h1 className="pull-left huge big-spacer-bottom">{title}</h1>
+
+      {additionalActions}
+    </header>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/InstanceNewCodeDefinitionComplianceWarning.tsx b/server/sonar-web/src/main/js/apps/create/project/components/InstanceNewCodeDefinitionComplianceWarning.tsx
new file mode 100644 (file)
index 0000000..7e0f2cc
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { getNewCodePeriod } from '../../../../api/newCodePeriod';
+import { AppStateContextProviderProps } from '../../../../app/components/app-state/AppStateContextProvider';
+import withAppStateContext from '../../../../app/components/app-state/withAppStateContext';
+import DocLink from '../../../../components/common/DocLink';
+import Link from '../../../../components/common/Link';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { isNewCodeDefinitionCompliant } from '../../../../helpers/periods';
+
+export type InstanceNewCodeDefinitionComplianceWarningProps = AppStateContextProviderProps;
+
+export function InstanceNewCodeDefinitionComplianceWarning({
+  appState: { canAdmin },
+}: InstanceNewCodeDefinitionComplianceWarningProps) {
+  const [isCompliant, setIsCompliant] = React.useState(true);
+
+  React.useEffect(() => {
+    async function fetchInstanceNCDOptionCompliance() {
+      const newCodeDefinition = await getNewCodePeriod();
+      setIsCompliant(isNewCodeDefinitionCompliant(newCodeDefinition));
+    }
+
+    fetchInstanceNCDOptionCompliance();
+  }, []);
+
+  if (isCompliant) {
+    return null;
+  }
+
+  return (
+    <Alert className="huge-spacer-bottom sw-max-w-[700px]" variant="warning">
+      <p className="sw-mb-2 sw-font-bold">
+        {translate('onboarding.create_project.new_code_option.warning.title')}
+      </p>
+      <p className="sw-mb-2">
+        <FormattedMessage
+          id="onboarding.create_project.new_code_option.warning.explanation"
+          defaultMessage={translate(
+            'onboarding.create_project.new_code_option.warning.explanation'
+          )}
+          values={{
+            action: canAdmin ? (
+              <FormattedMessage
+                id="onboarding.create_project.new_code_option.warning.explanation.action.admin"
+                defaultMessage={translate(
+                  'onboarding.create_project.new_code_option.warning.explanation.action.admin'
+                )}
+                values={{
+                  link: (
+                    <Link to="/admin/settings?category=new_code_period">
+                      {translate(
+                        'onboarding.create_project.new_code_option.warning.explanation.action.admin.link'
+                      )}
+                    </Link>
+                  ),
+                }}
+              />
+            ) : (
+              translate('onboarding.create_project.new_code_option.warning.explanation.action')
+            ),
+          }}
+        />
+      </p>
+      <p>
+        {translate('learn_more')}:&nbsp;
+        <DocLink to="/project-administration/defining-new-code/">
+          {translate('onboarding.create_project.new_code_option.warning.learn_more.link')}
+        </DocLink>
+      </p>
+    </Alert>
+  );
+}
+
+export default withAppStateContext(InstanceNewCodeDefinitionComplianceWarning);
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/PersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/components/PersonalAccessTokenForm.tsx
new file mode 100644 (file)
index 0000000..edba42d
--- /dev/null
@@ -0,0 +1,428 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import {
+  checkPersonalAccessTokenIsValid,
+  setAlmPersonalAccessToken,
+} from '../../../../api/alm-integrations';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { tokenExistedBefore } from '../utils';
+
+interface Props {
+  almSetting: AlmSettingsInstance;
+  resetPat: boolean;
+  onPersonalAccessTokenCreated: () => void;
+}
+
+interface State {
+  validationFailed: boolean;
+  validationErrorMessage?: string;
+  touched: boolean;
+  password: string;
+  username?: string;
+  submitting: boolean;
+  checkingPat: boolean;
+  firstConnection: boolean;
+}
+
+function getPatUrl(alm: AlmKeys, url = '') {
+  if (alm === AlmKeys.BitbucketServer) {
+    return `${url.replace(/\/$/, '')}/account`;
+  } else if (alm === AlmKeys.BitbucketCloud) {
+    return 'https://bitbucket.org/account/settings/app-passwords/new';
+  } else if (alm === AlmKeys.GitLab) {
+    return 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html';
+  }
+
+  return '';
+}
+
+export default class PersonalAccessTokenForm extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      checkingPat: false,
+      touched: false,
+      password: '',
+      submitting: false,
+      validationFailed: false,
+      firstConnection: false,
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.checkPATAndUpdateView();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (this.props.almSetting !== prevProps.almSetting) {
+      this.checkPATAndUpdateView();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  checkPATAndUpdateView = async () => {
+    const {
+      almSetting: { key },
+      resetPat,
+    } = this.props;
+
+    // We don't need to check PAT if we want to reset
+    if (!resetPat) {
+      this.setState({ checkingPat: true });
+      const { patIsValid, error } = await checkPersonalAccessTokenIsValid(key)
+        .then(({ status, error }) => ({ patIsValid: status, error }))
+        .catch(() => ({ patIsValid: status, error: translate('default_error_message') }));
+      if (patIsValid) {
+        this.props.onPersonalAccessTokenCreated();
+      }
+      if (this.mounted) {
+        // This is the initial message when no token was provided
+        if (tokenExistedBefore(error)) {
+          this.setState({
+            checkingPat: false,
+            firstConnection: true,
+          });
+        } else {
+          this.setState({
+            checkingPat: false,
+            validationFailed: true,
+            validationErrorMessage: error,
+          });
+        }
+      }
+    }
+  };
+
+  handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    this.setState({
+      touched: true,
+      username: event.target.value,
+    });
+  };
+
+  handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    this.setState({
+      touched: true,
+      password: event.target.value,
+    });
+  };
+
+  handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
+    const { password, username } = this.state;
+    const {
+      almSetting: { key },
+    } = this.props;
+
+    e.preventDefault();
+    if (password) {
+      this.setState({ submitting: true });
+
+      await setAlmPersonalAccessToken(key, password, username).catch(() => {
+        /* Set will not check pat validity. We need to check again so we will catch issue after */
+      });
+
+      const { status, error } = await checkPersonalAccessTokenIsValid(key)
+        .then(({ status, error }) => ({ status, error }))
+        .catch(() => ({ status: false, error: translate('default_error_message') }));
+
+      if (this.mounted && status) {
+        // Let's reset status,
+        this.setState({
+          checkingPat: false,
+          touched: false,
+          password: '',
+          submitting: false,
+          username: '',
+          validationFailed: false,
+        });
+        this.props.onPersonalAccessTokenCreated();
+      } else if (this.mounted) {
+        this.setState({
+          submitting: false,
+          touched: false,
+          validationFailed: true,
+          validationErrorMessage: error,
+        });
+      }
+    }
+  };
+
+  renderHelpBox(suffixTranslationKey: string) {
+    const {
+      almSetting: { alm, url },
+    } = this.props;
+
+    return (
+      <Alert className="big-spacer-left width-50" display="block" variant="info">
+        {alm === AlmKeys.BitbucketCloud && (
+          <>
+            <h3>
+              {translate(
+                'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.title'
+              )}
+            </h3>
+            <p className="big-spacer-top big-spacer-bottom">
+              {translate('onboarding.create_project.pat_help.instructions_username.bitbucketcloud')}
+            </p>
+
+            <div className="text-middle big-spacer-bottom">
+              <img
+                alt="" // Should be ignored by screen readers
+                className="spacer-right"
+                height="16"
+                src={`${getBaseUrl()}/images/alm/${AlmKeys.BitbucketServer}.svg`}
+              />
+              <a
+                href="https://bitbucket.org/account/settings/"
+                rel="noopener noreferrer"
+                target="_blank"
+              >
+                {translate(
+                  'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.link'
+                )}
+              </a>
+            </div>
+          </>
+        )}
+
+        <h3>{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}</h3>
+
+        <p className="big-spacer-top big-spacer-bottom">
+          {alm === AlmKeys.BitbucketServer ? (
+            <FormattedMessage
+              id="onboarding.create_project.pat_help.instructions"
+              defaultMessage={translate(
+                `onboarding.create_project.pat_help.bitbucket.instructions`
+              )}
+              values={{
+                menu: (
+                  <strong>
+                    {translate('onboarding.create_project.pat_help.bitbucket.instructions.menu')}
+                  </strong>
+                ),
+                button: (
+                  <strong>
+                    {translate('onboarding.create_project.pat_help.bitbucket.instructions.button')}
+                  </strong>
+                ),
+              }}
+            />
+          ) : (
+            <FormattedMessage
+              id="onboarding.create_project.pat_help.instructions"
+              defaultMessage={translate(
+                `onboarding.create_project.pat_help${suffixTranslationKey}.instructions`
+              )}
+              values={{
+                alm: translate('onboarding.alm', alm),
+              }}
+            />
+          )}
+        </p>
+
+        {(url || alm === AlmKeys.BitbucketCloud) && (
+          <div className="text-middle">
+            <img
+              alt="" // Should be ignored by screen readers
+              className="spacer-right"
+              height="16"
+              src={`${getBaseUrl()}/images/alm/${
+                alm === AlmKeys.BitbucketCloud ? AlmKeys.BitbucketServer : alm
+              }.svg`}
+            />
+            <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank">
+              {translate(`onboarding.create_project.pat_help${suffixTranslationKey}.link`)}
+            </a>
+          </div>
+        )}
+
+        <p className="big-spacer-top big-spacer-bottom">
+          {translate('onboarding.create_project.pat_help.instructions2', alm)}
+        </p>
+
+        <ul>
+          {alm === AlmKeys.BitbucketServer && (
+            <li>
+              <FormattedMessage
+                defaultMessage={translate(
+                  'onboarding.create_project.pat_help.bbs_permission_projects'
+                )}
+                id="onboarding.create_project.pat_help.bbs_permission_projects"
+                values={{
+                  perm: (
+                    <strong>
+                      {translate('onboarding.create_project.pat_help.read_permission')}
+                    </strong>
+                  ),
+                }}
+              />
+            </li>
+          )}
+          {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && (
+            <li>
+              <FormattedMessage
+                defaultMessage={translate(
+                  'onboarding.create_project.pat_help.bbs_permission_repos'
+                )}
+                id="onboarding.create_project.pat_help.bbs_permission_repos"
+                values={{
+                  perm: (
+                    <strong>
+                      {translate('onboarding.create_project.pat_help.read_permission')}
+                    </strong>
+                  ),
+                }}
+              />
+            </li>
+          )}
+
+          {alm === AlmKeys.GitLab && (
+            <li className="spacer-bottom">
+              <strong>
+                {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')}
+              </strong>
+            </li>
+          )}
+        </ul>
+      </Alert>
+    );
+  }
+
+  render() {
+    const {
+      almSetting: { alm },
+    } = this.props;
+    const {
+      checkingPat,
+      submitting,
+      touched,
+      password,
+      username,
+      validationFailed,
+      validationErrorMessage,
+      firstConnection,
+    } = this.state;
+
+    if (checkingPat) {
+      return <DeferredSpinner className="spacer-left" loading={true} />;
+    }
+
+    const suffixTranslationKey = alm === AlmKeys.BitbucketCloud ? '.bitbucketcloud' : '';
+
+    const isInvalid = validationFailed && !touched;
+    const canSubmit = Boolean(password) && (alm !== AlmKeys.BitbucketCloud || Boolean(username));
+    const submitButtonDiabled = isInvalid || submitting || !canSubmit;
+
+    const errorMessage =
+      validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm);
+
+    return (
+      <div className="display-flex-start">
+        <form className="width-50" onSubmit={this.handleSubmit}>
+          <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2>
+          <p className="big-spacer-top big-spacer-bottom">
+            {translate('onboarding.create_project.pat_form.help', alm)}
+          </p>
+
+          {!firstConnection && (
+            <Alert className="big-spacer-right" variant="warning">
+              <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
+              <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
+            </Alert>
+          )}
+
+          {alm === AlmKeys.BitbucketCloud && (
+            <ValidationInput
+              error={undefined}
+              labelHtmlFor="enter_username_validation"
+              isInvalid={false}
+              isValid={false}
+              label={translate('onboarding.create_project.enter_username')}
+              required={true}
+            >
+              <input
+                autoFocus={true}
+                className={classNames('input-super-large', {
+                  'is-invalid': isInvalid,
+                })}
+                id="enter_username_validation"
+                minLength={1}
+                name="username"
+                value={username}
+                onChange={this.handleUsernameChange}
+                type="text"
+              />
+            </ValidationInput>
+          )}
+
+          <ValidationInput
+            error={errorMessage}
+            labelHtmlFor="personal_access_token_validation"
+            isInvalid={false}
+            isValid={false}
+            label={translate(`onboarding.create_project.enter_pat${suffixTranslationKey}`)}
+            required={true}
+          >
+            <input
+              autoFocus={alm !== AlmKeys.BitbucketCloud}
+              className={classNames('input-super-large', {
+                'is-invalid': isInvalid,
+              })}
+              id="personal_access_token_validation"
+              minLength={1}
+              value={password}
+              onChange={this.handlePasswordChange}
+              type="text"
+            />
+          </ValidationInput>
+
+          <ValidationInput
+            error={errorMessage}
+            labelHtmlFor="personal_access_token_submit"
+            isInvalid={isInvalid}
+            isValid={false}
+            label={null}
+          >
+            <SubmitButton disabled={submitButtonDiabled}>{translate('save')}</SubmitButton>
+            <DeferredSpinner className="spacer-left" loading={submitting} />
+          </ValidationInput>
+        </form>
+
+        {this.renderHelpBox(suffixTranslationKey)}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/WrongBindingCountAlert.tsx b/server/sonar-web/src/main/js/apps/create/project/components/WrongBindingCountAlert.tsx
new file mode 100644 (file)
index 0000000..215cdf2
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { getGlobalSettingsUrl } from '../../../../helpers/urls';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants';
+
+export interface WrongBindingCountAlertProps {
+  alm: AlmKeys;
+  canAdmin: boolean;
+}
+
+export default function WrongBindingCountAlert(props: WrongBindingCountAlertProps) {
+  const { alm, canAdmin } = props;
+
+  return (
+    <Alert variant="error">
+      {canAdmin ? (
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.wrong_binding_count.admin')}
+          id="onboarding.create_project.wrong_binding_count.admin"
+          values={{
+            alm: translate('onboarding.alm', alm),
+            url: (
+              <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
+                {translate('settings.page')}
+              </Link>
+            ),
+          }}
+        />
+      ) : (
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.wrong_binding_count')}
+          id="onboarding.create_project.wrong_binding_count"
+          values={{
+            alm: translate('onboarding.alm', alm),
+          }}
+        />
+      )}
+    </Alert>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx
new file mode 100644 (file)
index 0000000..5a611d8
--- /dev/null
@@ -0,0 +1,325 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { debounce, isEmpty } from 'lodash';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { createProject, doesComponentExists } from '../../../../api/components';
+import { getValue } from '../../../../api/settings';
+import DocLink from '../../../../components/common/DocLink';
+import ProjectKeyInput from '../../../../components/common/ProjectKeyInput';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import MandatoryFieldsExplanation from '../../../../components/ui/MandatoryFieldsExplanation';
+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 CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { PROJECT_NAME_MAX_LEN } from '../constants';
+
+interface Props {
+  branchesEnabled: boolean;
+  onProjectCreate: (projectKey: string) => void;
+}
+
+interface State {
+  projectName: string;
+  projectNameError?: string;
+  projectNameTouched: boolean;
+  projectKey: string;
+  projectKeyError?: string;
+  projectKeyTouched: boolean;
+  validatingProjectKey: boolean;
+  mainBranchName: string;
+  mainBranchNameError?: string;
+  mainBranchNameTouched: boolean;
+  submitting: boolean;
+}
+
+const DEBOUNCE_DELAY = 250;
+
+type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;
+
+export default class ManualProjectCreate extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      projectKey: '',
+      projectName: '',
+      submitting: false,
+      projectKeyTouched: false,
+      projectNameTouched: false,
+      mainBranchName: 'main',
+      mainBranchNameTouched: false,
+      validatingProjectKey: false,
+    };
+    this.checkFreeKey = debounce(this.checkFreeKey, DEBOUNCE_DELAY);
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchMainBranchName();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchMainBranchName = async () => {
+    const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });
+
+    if (this.mounted && mainBranchName.value !== undefined) {
+      this.setState({ mainBranchName: mainBranchName.value });
+    }
+  };
+
+  checkFreeKey = (key: string) => {
+    this.setState({ validatingProjectKey: true });
+
+    doesComponentExists({ component: key })
+      .then((alreadyExist) => {
+        if (this.mounted && key === this.state.projectKey) {
+          this.setState({
+            projectKeyError: alreadyExist
+              ? translate('onboarding.create_project.project_key.taken')
+              : undefined,
+            validatingProjectKey: false,
+          });
+        }
+      })
+      .catch(() => {
+        if (this.mounted && key === this.state.projectKey) {
+          this.setState({ projectKeyError: undefined, validatingProjectKey: false });
+        }
+      });
+  };
+
+  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)
+    );
+  }
+
+  handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    const { projectKey, projectName, mainBranchName } = this.state;
+    if (this.canSubmit(this.state)) {
+      this.setState({ submitting: true });
+      createProject({
+        project: projectKey,
+        name: (projectName || projectKey).trim(),
+        mainBranch: mainBranchName,
+      }).then(
+        ({ project }) => this.props.onProjectCreate(project.key),
+        () => {
+          if (this.mounted) {
+            this.setState({ submitting: false });
+          }
+        }
+      );
+    }
+  };
+
+  handleProjectKeyChange = (projectKey: string, fromUI = false) => {
+    const projectKeyError = this.validateKey(projectKey);
+
+    this.setState({
+      projectKey,
+      projectKeyError,
+      projectKeyTouched: fromUI,
+    });
+
+    if (projectKeyError === undefined) {
+      this.checkFreeKey(projectKey);
+    }
+  };
+
+  handleProjectNameChange = (projectName: string, fromUI = false) => {
+    this.setState(
+      {
+        projectName,
+        projectNameError: this.validateName(projectName),
+        projectNameTouched: fromUI,
+      },
+      () => {
+        if (!this.state.projectKeyTouched) {
+          const sanitizedProjectKey = this.state.projectName
+            .trim()
+            .replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
+          this.handleProjectKeyChange(sanitizedProjectKey);
+        }
+      }
+    );
+  };
+
+  handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
+    this.setState({
+      mainBranchName,
+      mainBranchNameError: this.validateMainBranchName(mainBranchName),
+      mainBranchNameTouched: fromUI,
+    });
+  };
+
+  validateKey = (projectKey: string) => {
+    const result = validateProjectKey(projectKey);
+    return result === ProjectKeyValidationResult.Valid
+      ? undefined
+      : translate('onboarding.create_project.project_key.error', result);
+  };
+
+  validateName = (projectName: string) => {
+    if (isEmpty(projectName)) {
+      return translate('onboarding.create_project.display_name.error.empty');
+    }
+    return undefined;
+  };
+
+  validateMainBranchName = (mainBranchName: string) => {
+    if (isEmpty(mainBranchName)) {
+      return translate('onboarding.create_project.main_branch_name.error.empty');
+    }
+    return undefined;
+  };
+
+  render() {
+    const {
+      projectKey,
+      projectKeyError,
+      projectKeyTouched,
+      projectName,
+      projectNameError,
+      projectNameTouched,
+      validatingProjectKey,
+      mainBranchName,
+      mainBranchNameError,
+      mainBranchNameTouched,
+      submitting,
+    } = this.state;
+    const { branchesEnabled } = this.props;
+
+    const touched = Boolean(projectKeyTouched || projectNameTouched);
+    const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
+    const projectNameIsValid = projectNameTouched && projectNameError === undefined;
+    const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
+    const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;
+
+    return (
+      <>
+        <CreateProjectPageHeader title={translate('onboarding.create_project.setup_manually')} />
+
+        <InstanceNewCodeDefinitionComplianceWarning />
+
+        <form id="create-project-manual" onSubmit={this.handleFormSubmit}>
+          <MandatoryFieldsExplanation className="big-spacer-bottom" />
+
+          <ValidationInput
+            className="form-field"
+            description={translate('onboarding.create_project.display_name.description')}
+            error={projectNameError}
+            labelHtmlFor="project-name"
+            isInvalid={projectNameIsInvalid}
+            isValid={projectNameIsValid}
+            label={translate('onboarding.create_project.display_name')}
+            required={true}
+          >
+            <input
+              className={classNames('input-super-large', {
+                'is-invalid': projectNameIsInvalid,
+                'is-valid': projectNameIsValid,
+              })}
+              id="project-name"
+              maxLength={PROJECT_NAME_MAX_LEN}
+              minLength={1}
+              onChange={(e) => this.handleProjectNameChange(e.currentTarget.value, true)}
+              type="text"
+              value={projectName}
+              autoFocus={true}
+            />
+          </ValidationInput>
+          <ProjectKeyInput
+            error={projectKeyError}
+            label={translate('onboarding.create_project.project_key')}
+            onProjectKeyChange={(e) => this.handleProjectKeyChange(e.currentTarget.value, true)}
+            projectKey={projectKey}
+            touched={touched}
+            validating={validatingProjectKey}
+          />
+
+          <ValidationInput
+            className="form-field"
+            description={
+              <FormattedMessage
+                id="onboarding.create_project.main_branch_name.description"
+                defaultMessage={translate('onboarding.create_project.main_branch_name.description')}
+                values={{
+                  learn_more: (
+                    <DocLink to="/analyzing-source-code/branches/branch-analysis">
+                      {translate('learn_more')}
+                    </DocLink>
+                  ),
+                }}
+              />
+            }
+            error={mainBranchNameError}
+            labelHtmlFor="main-branch-name"
+            isInvalid={mainBranchNameIsInvalid}
+            isValid={mainBranchNameIsValid}
+            label={translate('onboarding.create_project.main_branch_name')}
+            required={true}
+          >
+            <input
+              id="main-branch-name"
+              className={classNames('input-super-large', {
+                'is-invalid': mainBranchNameIsInvalid,
+                'is-valid': mainBranchNameIsValid,
+              })}
+              minLength={1}
+              onChange={(e) => this.handleBranchNameChange(e.currentTarget.value, true)}
+              type="text"
+              value={mainBranchName}
+            />
+          </ValidationInput>
+
+          <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
+            {translate('set_up')}
+          </SubmitButton>
+          <DeferredSpinner className="spacer-left" loading={submitting} />
+        </form>
+
+        {branchesEnabled && (
+          <Alert variant="info" display="inline" className="big-spacer-top">
+            {translate('onboarding.create_project.pr_decoration.information')}
+          </Alert>
+        )}
+      </>
+    );
+  }
+}
index 1ee624a83945e900b2872a7e991d9f9989e1e057..c5355fd75d33b29c7198327bc6dac2da412954ea 100644 (file)
   filter: grayscale(100%);
 }
 
-.create-project-manual {
-  display: flex !important;
-  justify-content: space-between;
-}
-
 .create-project-azdo-repo {
   width: 410px;
   min-height: 40px;
index 59e62a8bf8d8757961abe8c5078cfffaedd6ee8b..73736fd7d7d527af5d5de13d7902f55d1c4a494c 100644 (file)
@@ -17,8 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { NewCodePeriodSettingType } from '../../types/types';
-import { getPeriodLabel } from '../periods';
+import { NewCodePeriod, NewCodePeriodSettingType } from '../../types/types';
+import { getPeriodLabel, isNewCodeDefinitionCompliant } from '../periods';
 import { mockPeriod } from '../testMocks';
 
 const formatter = jest.fn((v) => v);
@@ -110,3 +110,19 @@ describe('getPeriodLabel', () => {
     expect(formatter).toHaveBeenCalledTimes(1);
   });
 });
+
+describe('isNewCodeDefinitionCompliant', () => {
+  it.each([
+    [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '0' }, false],
+    [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '15' }, true],
+    [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '91' }, false],
+    [{ type: NewCodePeriodSettingType.PREVIOUS_VERSION }, true],
+    [{ type: NewCodePeriodSettingType.REFERENCE_BRANCH }, true],
+    [{ type: NewCodePeriodSettingType.SPECIFIC_ANALYSIS }, true],
+  ])(
+    'should test for new code definition compliance properly',
+    (newCodePeriod: NewCodePeriod, result: boolean) => {
+      expect(isNewCodeDefinitionCompliant(newCodePeriod)).toEqual(result);
+    }
+  );
+});
index 376a61c2a9c659289d3a17de51641c8433dac345..2ffa7a88a8034deebf43f986585e337f3c6825d2 100644 (file)
@@ -20,7 +20,7 @@
 import { parseDate } from '../helpers/dates';
 import { translate, translateWithParameters } from '../helpers/l10n';
 import { ApplicationPeriod } from '../types/application';
-import { NewCodePeriodSettingType, Period } from '../types/types';
+import { NewCodePeriod, NewCodePeriodSettingType, Period } from '../types/types';
 
 export function getPeriodLabel(
   period: Period | undefined,
@@ -68,3 +68,19 @@ export function isApplicationPeriod(
 ): period is ApplicationPeriod {
   return (period as ApplicationPeriod).project !== undefined;
 }
+
+const MIN_NUMBER_OF_DAYS = 1;
+const MAX_NUMBER_OF_DAYS = 90;
+
+export function isNewCodeDefinitionCompliant(newCodePeriod: NewCodePeriod) {
+  switch (newCodePeriod.type) {
+    case NewCodePeriodSettingType.NUMBER_OF_DAYS:
+      return (
+        newCodePeriod.value !== undefined &&
+        MIN_NUMBER_OF_DAYS <= +newCodePeriod.value &&
+        +newCodePeriod.value <= MAX_NUMBER_OF_DAYS
+      );
+    default:
+      return true;
+  }
+}
index 94901ddc87ab48c96184e3c4d63529fc62a0a0a8..0edbcf7a554ea7aeb793a719f7211d198345dd15 100644 (file)
@@ -398,7 +398,7 @@ export interface MyProject {
 }
 
 export interface NewCodePeriod {
-  type?: NewCodePeriodSettingType;
+  type: NewCodePeriodSettingType;
   value?: string;
   effectiveValue?: string;
   inherited?: boolean;
index 26561038619a81122ea389d9f3c93041a068e5ab..19f4a4e8f7fd35f3edd31da2b777df117afe647d 100644 (file)
@@ -3801,6 +3801,13 @@ onboarding.create_project.gitlab.title=Gitlab project onboarding
 onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}.
 onboarding.create_project.gitlab.link=See on GitLab
 
+onboarding.create_project.new_code_option.warning.title=Your global new code definition is not compliant with the Clean as You Code methodology
+onboarding.create_project.new_code_option.warning.explanation=New projects use the global new code definition by default. {action} so that new projects benefit from the Clean as You Code methodology by default.
+onboarding.create_project.new_code_option.warning.explanation.action=We recommend that you ask an administrator of this SonarQube instance to update the global new code definition
+onboarding.create_project.new_code_option.warning.explanation.action.admin=We recommend that you update the global new code definition under {link}
+onboarding.create_project.new_code_option.warning.explanation.action.admin.link=General Settings - New Code
+onboarding.create_project.new_code_option.warning.learn_more.link=Defining New Code
+
 onboarding.token.header=Provide a token
 onboarding.token.text=The token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.
 onboarding.token.text.PROJECT_ANALYSIS_TOKEN=The project token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.