]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR 22131 Monorepo for bitbucket server (#11064)
authorShane Findley <shane.findley@sonarsource.com>
Fri, 3 May 2024 06:14:51 +0000 (08:14 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 3 May 2024 20:02:49 +0000 (20:02 +0000)
20 files changed:
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx
server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 576e5bd092e662c541df9ab7bd86ccec3ba27fa3..7ef68d6dea5aac14ad0127ca97f81bd3c94908f8 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { LabelValueSelectOption } from 'design-system';
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
 import { GroupBase } from 'react-select';
-import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter';
+import { useLocation } from '~sonar-aligned/components/hoc/withRouter';
 import {
   getAzureProjects,
   getAzureRepositories,
   searchAzureRepositories,
 } from '../../../../api/alm-integrations';
 import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { AlmKeys } from '../../../../types/alm-settings';
 import { DopSetting } from '../../../../types/dop-translation';
 import { Dict } from '../../../../types/types';
 import { ImportProjectParam } from '../CreateProjectPage';
 import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
+import { useProjectCreate } from '../useProjectCreate';
+import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
 import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
 import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
 
@@ -42,51 +44,41 @@ interface Props {
   onProjectSetupDone: (importProjects: ImportProjectParam) => void;
 }
 
-export default function AzureProjectCreate(props: Readonly<Props>) {
-  const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;
-  const [isLoading, setIsLoading] = useState(false);
+export default function AzureProjectCreate({
+  dopSettings,
+  isLoadingBindings,
+  onProjectSetupDone,
+}: Readonly<Props>) {
+  const {
+    almInstances,
+    handlePersonalAccessTokenCreated,
+    handleSelectRepository: defaultRepositorySelect,
+    isLoadingRepositories,
+    isMonorepoSetup,
+    onSelectedAlmInstanceChange,
+    organizations: projects,
+    repositories,
+    searchQuery,
+    selectedAlmInstance,
+    selectedDopSetting,
+    selectedRepository,
+    setSearchQuery,
+    setIsLoadingRepositories,
+    setOrganizations: setProjects,
+    setRepositories,
+    setSelectedDopSetting,
+    setSelectedRepository,
+    setShowPersonalAccessTokenForm,
+    showPersonalAccessTokenForm,
+  } = useProjectCreate<AzureRepository, Dict<AzureRepository[]>, AzureProject>(
+    AlmKeys.Azure,
+    dopSettings,
+    ({ name }) => name,
+  );
+
   const [loadingRepositories, setLoadingRepositories] = useState<Dict<boolean>>({});
-  const [isSearching, setIsSearching] = useState(false);
-  const [projects, setProjects] = useState<AzureProject[] | undefined>();
-  const [repositories, setRepositories] = useState<Dict<AzureRepository[]>>({});
-  const [searchQuery, setSearchQuery] = useState<string>('');
-  const [searchResults, setSearchResults] = useState<AzureRepository[] | undefined>();
-  const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting | undefined>();
-  const [selectedRepository, setSelectedRepository] = useState<AzureRepository>();
-  const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState(true);
 
   const location = useLocation();
-  const router = useRouter();
-
-  const almInstances = useMemo(
-    () =>
-      dopSettings?.map((dopSetting) => ({
-        alm: dopSetting.type,
-        key: dopSetting.key,
-        url: dopSetting.url,
-      })) ?? [],
-    [dopSettings],
-  );
-  const isMonorepoSetup = location.query?.mono === 'true';
-  const hasDopSettings = Boolean(dopSettings?.length);
-  const selectedAlmInstance = useMemo(
-    () =>
-      selectedDopSetting && {
-        alm: selectedDopSetting.type,
-        key: selectedDopSetting.key,
-        url: selectedDopSetting.url,
-      },
-    [selectedDopSetting],
-  );
-  const repositoryOptions = useMemo(
-    () => transformToOptions(projects ?? [], repositories),
-    [projects, repositories],
-  );
-
-  const cleanUrl = useCallback(() => {
-    delete location.query.resetPat;
-    router.replace(location);
-  }, [location, router]);
 
   const fetchAzureProjects = useCallback(async (): Promise<AzureProject[] | undefined> => {
     if (selectedDopSetting === undefined) {
@@ -119,13 +111,13 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
       return;
     }
 
-    setIsLoading(true);
+    setIsLoadingRepositories(true);
     let projects: AzureProject[] | undefined;
     try {
       projects = await fetchAzureProjects();
     } catch (_) {
       setShowPersonalAccessTokenForm(true);
-      setIsLoading(false);
+      setIsLoadingRepositories(false);
       return;
     }
 
@@ -171,8 +163,17 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
     }
 
     setProjects(projects);
-    setIsLoading(false);
-  }, [fetchAzureProjects, fetchAzureRepositories, isMonorepoSetup, showPersonalAccessTokenForm]);
+    setIsLoadingRepositories(false);
+  }, [
+    fetchAzureProjects,
+    fetchAzureRepositories,
+    isMonorepoSetup,
+    setIsLoadingRepositories,
+    setProjects,
+    setRepositories,
+    setShowPersonalAccessTokenForm,
+    showPersonalAccessTokenForm,
+  ]);
 
   const handleImportRepository = useCallback(
     (selectedRepository: AzureRepository) => {
@@ -205,6 +206,19 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
     [onProjectSetupDone, selectedRepository?.projectName],
   );
 
+  const { isSearching, onSearch, onSelectRepository, searchResults } =
+    useProjectRepositorySearch<AzureRepository>({
+      defaultRepositorySelect,
+      fetchData,
+      fetchSearchResults: (query: string, dopKey: string) => searchAzureRepositories(dopKey, query),
+      getRepositoryKey: ({ name }) => name,
+      isMonorepoSetup,
+      selectedDopSetting,
+      setSearchQuery,
+      setSelectedRepository,
+      setShowPersonalAccessTokenForm,
+    });
+
   const handleOpenProject = useCallback(
     async (projectName: string) => {
       if (searchResults !== undefined) {
@@ -224,75 +238,19 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
       }));
       setRepositories((repositories) => ({ ...repositories, [projectName]: projectRepos }));
     },
-    [fetchAzureRepositories, searchResults],
+    [fetchAzureRepositories, searchResults, setRepositories],
   );
 
-  const handlePersonalAccessTokenCreate = useCallback(() => {
-    cleanUrl();
-    setShowPersonalAccessTokenForm(false);
-  }, [cleanUrl]);
-
-  const handleSearchRepositories = useCallback(
-    async (searchQuery: string) => {
-      if (!selectedDopSetting) {
-        return;
-      }
-
-      if (searchQuery.length === 0) {
-        setSearchResults(undefined);
-        setSearchQuery('');
-        return;
-      }
-
-      setIsSearching(true);
-
-      const searchResults: AzureRepository[] = await searchAzureRepositories(
-        selectedDopSetting.key,
-        searchQuery,
-      )
-        .then(({ repositories }) => repositories)
-        .catch(() => []);
-
-      setIsSearching(false);
-      setSearchQuery(searchQuery);
-      setSearchResults(searchResults);
-    },
-    [selectedDopSetting],
-  );
-
-  const handleSelectRepository = useCallback(
-    (repositoryKey: string) => {
-      setSelectedRepository(
-        Object.values(repositories)
-          .flat()
-          .find(({ name }) => name === repositoryKey),
-      );
-    },
-    [repositories],
-  );
-
-  const onSelectedAlmInstanceChange = useCallback(
-    (almInstance: AlmSettingsInstance) => {
-      setSelectedDopSetting(dopSettings?.find((dopSetting) => dopSetting.key === almInstance.key));
-    },
-    [dopSettings],
-  );
-
-  useEffect(() => {
-    setSelectedDopSetting(dopSettings?.[0]);
-    // We want to update this value only when the list of DOP settings changes from empty to not-empty (or vice-versa)
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [hasDopSettings]);
-
-  useEffect(() => {
-    setSearchResults(undefined);
-    setSearchQuery('');
-    setShowPersonalAccessTokenForm(true);
-  }, [isMonorepoSetup, selectedDopSetting]);
+  const repositoryOptions = useMemo(() => {
+    if (searchResults) {
+      const dict = projects?.reduce((acc: Dict<AzureRepository[]>, { name }) => {
+        return { ...acc, [name]: searchResults?.filter((o) => o.projectName === name) };
+      }, {});
+      return transformToOptions(projects ?? [], dict);
+    }
 
-  useEffect(() => {
-    fetchData();
-  }, [fetchData]);
+    return transformToOptions(projects ?? [], repositories);
+  }, [projects, repositories, searchResults]);
 
   return isMonorepoSetup ? (
     <MonorepoProjectCreate
@@ -300,17 +258,17 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
       error={false}
       loadingBindings={isLoadingBindings}
       loadingOrganizations={false}
-      loadingRepositories={isLoading}
+      loadingRepositories={isLoadingRepositories}
       onProjectSetupDone={handleMonorepoSetupDone}
-      onSearchRepositories={setSearchQuery}
+      onSearchRepositories={onSearch}
       onSelectDopSetting={setSelectedDopSetting}
-      onSelectRepository={handleSelectRepository}
+      onSelectRepository={onSelectRepository}
       personalAccessTokenComponent={
-        !isLoading &&
+        !isLoadingRepositories &&
         selectedAlmInstance && (
           <AzurePersonalAccessTokenForm
             almSetting={selectedAlmInstance}
-            onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
+            onPersonalAccessTokenCreate={handlePersonalAccessTokenCreated}
             resetPat={Boolean(location.query.resetPat)}
           />
         )
@@ -324,12 +282,12 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
   ) : (
     <AzureCreateProjectRenderer
       almInstances={almInstances}
-      loading={isLoading || isLoadingBindings}
+      loading={isLoadingRepositories || isLoadingBindings}
       loadingRepositories={loadingRepositories}
       onImportRepository={handleImportRepository}
       onOpenProject={handleOpenProject}
-      onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
-      onSearch={handleSearchRepositories}
+      onPersonalAccessTokenCreate={handlePersonalAccessTokenCreated}
+      onSearch={onSearch}
       onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
       projects={projects}
       repositories={repositories}
@@ -345,11 +303,14 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
 
 function transformToOptions(
   projects: AzureProject[],
-  repositories: Dict<AzureRepository[]>,
+  repositories?: Dict<AzureRepository[]>,
 ): Array<GroupBase<LabelValueSelectOption<string>>> {
   return projects.map(({ name: projectName }) => ({
     label: projectName,
-    options: repositories[projectName]?.map(transformToOption) ?? [],
+    options:
+      repositories?.[projectName] !== undefined
+        ? repositories[projectName].map(transformToOption)
+        : [],
   }));
 }
 
index 42a335f761692d4eec096d34433c1fdc4d386b65..7db5f88a75a66ef519c19bb31e64ee7895f9d2ce 100644 (file)
@@ -51,7 +51,7 @@ export interface AzureProjectCreateRendererProps {
   onPersonalAccessTokenCreate: () => void;
   onSearch: (query: string) => void;
   projects?: AzureProject[];
-  repositories: Dict<AzureRepository[]>;
+  repositories?: Dict<AzureRepository[]>;
   searching?: boolean;
   searchResults?: AzureRepository[];
   searchQuery?: string;
index 1bdb1c4409120a5e41508f02f5c0b7eb40772477..5cd8189660c7370dfb42aa74b95a723f7c49e609 100644 (file)
@@ -34,7 +34,7 @@ export interface AzureProjectsListProps {
   onOpenProject: (key: string) => void;
   onImportRepository: (repository: AzureRepository) => void;
   projects?: AzureProject[];
-  repositories: Dict<AzureRepository[]>;
+  repositories?: Dict<AzureRepository[]>;
   searchResults?: AzureRepository[];
   searchQuery?: string;
 }
@@ -121,7 +121,7 @@ export default function AzureProjectsList(props: AzureProjectsListProps) {
             repositories={
               searchResults
                 ? searchResults.filter((s) => s.projectName === p.name)
-                : repositories[p.name]
+                : repositories?.[p.name]
             }
             searchQuery={searchQuery}
             startsOpen={searchResults !== undefined || i === 0}
index 56c40b24f6b867aac0e74ba318e6bc56cf4248ff..1c206512972e9ba577de904855441c34bf324b5d 100644 (file)
@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
 import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
 import { useProjectCreate } from '../useProjectCreate';
-import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
+import { useRepositorySearch } from '../useRepositorySearch';
 import BitbucketCloudPersonalAccessTokenForm from './BitbucketCloudPersonalAccessTokenForm';
 import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';
 
@@ -49,6 +49,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
   });
 
   const {
+    almInstances,
     handlePersonalAccessTokenCreated,
     handleSelectRepository,
     isInitialized,
@@ -61,6 +62,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
     resetLoading,
     resetPersonalAccessToken,
     searchQuery,
+    selectedAlmInstance,
     selectedDopSetting,
     selectedRepository,
     setIsInitialized,
@@ -69,11 +71,10 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
     setSearchQuery,
     setShowPersonalAccessTokenForm,
     showPersonalAccessTokenForm,
-  } = useProjectCreate<BitbucketCloudRepository, undefined>(
+  } = useProjectCreate<BitbucketCloudRepository, BitbucketCloudRepository[], undefined>(
     AlmKeys.BitbucketCloud,
     dopSettings,
     ({ slug }) => slug,
-    REPOSITORY_PAGE_SIZE,
   );
 
   const location = useLocation();
@@ -153,7 +154,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
     [onProjectSetupDone, selectedDopSetting],
   );
 
-  const { isSearching, onSearch } = useProjectRepositorySearch(
+  const { isSearching, onSearch } = useRepositorySearch(
     AlmKeys.BitbucketCloud,
     fetchRepositories,
     isInitialized,
@@ -192,21 +193,8 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
     />
   ) : (
     <BitbucketCloudProjectCreateRenderer
+      almInstances={almInstances}
       isLastPage={isLastPage}
-      selectedAlmInstance={
-        selectedDopSetting
-          ? {
-              alm: selectedDopSetting.type,
-              key: selectedDopSetting.key,
-              url: selectedDopSetting.url,
-            }
-          : undefined
-      }
-      almInstances={dopSettings?.map((instance) => ({
-        alm: instance.type,
-        key: instance.key,
-        url: instance.url,
-      }))}
       loadingMore={isLoadingMoreRepositories}
       loading={isLoadingRepositories || isLoadingBindings}
       onImport={handleImportRepository}
@@ -215,9 +203,10 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
       onSearch={onSearch}
       onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
       repositories={repositories}
+      resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
       searching={isSearching}
       searchQuery={searchQuery}
-      resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
+      selectedAlmInstance={selectedAlmInstance}
       showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
     />
   );
index 457c50b872413722db177f0dc987d8f3ee79ee2d..dbc28d6e7eef50d574de2c07557a3fe7707eb2b0 100644 (file)
@@ -22,11 +22,8 @@ import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { queryToSearchString } from '~sonar-aligned/helpers/urls';
 import { translate } from '../../../../helpers/l10n';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../../types/alm-integration';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import { Dict } from '../../../../types/types';
 import { CreateProjectModes } from '../types';
 import BitbucketRepositories from './BitbucketRepositories';
 import BitbucketSearchResults from './BitbucketSearchResults';
@@ -35,7 +32,7 @@ export interface BitbucketImportRepositoryFormProps {
   onSearch: (query: string) => void;
   onImportRepository: (repo: BitbucketRepository) => void;
   projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
+  projectRepositories?: Dict<BitbucketRepository[]>;
   searching: boolean;
   searchResults?: BitbucketRepository[];
 }
index 21067c5e98215eda92157feebc984026e49a8664..a0435481bc53932ab22e518227749d28e9f08096 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import * as React from 'react';
-import { Location, Router } from '~sonar-aligned/types/router';
+import { LabelValueSelectOption } from 'design-system';
+import React, { useCallback, useMemo } from 'react';
+import { GroupBase } from 'react-select';
+import { useLocation } from '~sonar-aligned/components/hoc/withRouter';
 import {
   getBitbucketServerProjects,
   getBitbucketServerRepositories,
   searchForBitbucketServerRepositories,
 } from '../../../../api/alm-integrations';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { DopSetting } from '../../../../types/dop-translation';
+import { Dict } from '../../../../types/types';
 import { ImportProjectParam } from '../CreateProjectPage';
-import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
+import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
+import { useProjectCreate } from '../useProjectCreate';
+import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
 import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
+import BitbucketServerPersonalAccessTokenForm from './BitbucketServerPersonalAccessTokenForm';
 
 interface Props {
-  almInstances: AlmSettingsInstance[];
-  loadingBindings: boolean;
-  location: Location;
-  router: Router;
+  dopSettings: DopSetting[];
+  isLoadingBindings: boolean;
   onProjectSetupDone: (importProjects: ImportProjectParam) => void;
 }
 
-interface State {
-  selectedAlmInstance?: AlmSettingsInstance;
-  loading: boolean;
-  projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
-  searching: boolean;
-  searchResults?: BitbucketRepository[];
-  showPersonalAccessTokenForm: boolean;
-}
-
-export default class BitbucketProjectCreate extends React.PureComponent<Props, State> {
-  mounted = false;
+export default function BitbucketProjectCreate({
+  dopSettings,
+  isLoadingBindings,
+  onProjectSetupDone,
+}: Readonly<Props>) {
+  const {
+    almInstances,
+    handlePersonalAccessTokenCreated,
+    handleSelectRepository: defaultRepositorySelect,
+    isLoadingRepositories,
+    isMonorepoSetup,
+    onSelectedAlmInstanceChange,
+    organizations: projects,
+    repositories,
+    resetPersonalAccessToken,
+    searchQuery,
+    selectedAlmInstance,
+    selectedDopSetting,
+    selectedRepository,
+    setIsLoadingRepositories,
+    setOrganizations: setProjects,
+    setRepositories,
+    setSearchQuery,
+    setSelectedDopSetting,
+    setSelectedRepository,
+    setShowPersonalAccessTokenForm,
+    showPersonalAccessTokenForm,
+  } = useProjectCreate<BitbucketRepository, Dict<BitbucketRepository[]>, BitbucketProject>(
+    AlmKeys.BitbucketServer,
+    dopSettings,
+    ({ slug }) => slug,
+  );
 
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      selectedAlmInstance: props.almInstances[0],
-      loading: false,
-      searching: false,
-      showPersonalAccessTokenForm: true,
-    };
-  }
+  const location = useLocation();
 
-  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().catch(() => {
-          /* noop */
-        });
-      });
+  const fetchBitbucketProjects = useCallback((): Promise<BitbucketProject[] | undefined> => {
+    if (!selectedDopSetting) {
+      return Promise.resolve(undefined);
     }
-  }
 
-  componentWillUnmount() {
-    this.mounted = false;
-  }
+    return getBitbucketServerProjects(selectedDopSetting.key).then(({ projects }) => projects);
+  }, [selectedDopSetting]);
 
-  fetchInitialData = async () => {
-    const { showPersonalAccessTokenForm } = this.state;
+  const fetchBitbucketRepositories = useCallback(
+    (projects: BitbucketProject[]): Promise<Dict<BitbucketRepository[]> | undefined> => {
+      if (!selectedDopSetting) {
+        return Promise.resolve(undefined);
+      }
 
-    if (!showPersonalAccessTokenForm) {
-      this.setState({ loading: true });
-      const projects = await this.fetchBitbucketProjects().catch(() => undefined);
+      return Promise.all(
+        projects.map((p) => {
+          return getBitbucketServerRepositories(selectedDopSetting.key, p.name).then(
+            ({ 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);
 
-      let projectRepositories;
-      if (projects && projects.length > 0) {
-        projectRepositories = await this.fetchBitbucketRepositories(projects).catch(
-          () => undefined,
-        );
-      }
+              return {
+                repositories: filteredRepositories,
+                projectKey: p.key,
+              };
+            },
+          );
+        }),
+      ).then((results) => {
+        return results.reduce((acc: Dict<BitbucketRepository[]>, { projectKey, repositories }) => {
+          return { ...acc, [projectKey]: repositories };
+        }, {});
+      });
+    },
+    [selectedDopSetting],
+  );
 
-      if (this.mounted) {
-        this.setState({
-          projects,
-          projectRepositories,
-          loading: false,
+  const handleImportRepository = useCallback(
+    (selectedRepository: BitbucketRepository) => {
+      if (selectedDopSetting) {
+        onProjectSetupDone({
+          creationMode: CreateProjectModes.BitbucketServer,
+          almSetting: selectedDopSetting.key,
+          monorepo: false,
+          projects: [
+            {
+              projectKey: selectedRepository.projectKey,
+              repositorySlug: selectedRepository.slug,
+            },
+          ],
         });
       }
-    }
-  };
+    },
+    [onProjectSetupDone, selectedDopSetting],
+  );
 
-  fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
-    const { selectedAlmInstance } = this.state;
+  const handleMonorepoSetupDone = useCallback(
+    (monorepoSetup: ImportProjectParam) => {
+      const bitbucketMonorepoSetup = {
+        ...monorepoSetup,
+        projectIdentifier: selectedRepository?.projectKey,
+      };
 
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
-    }
+      onProjectSetupDone(bitbucketMonorepoSetup);
+    },
+    [onProjectSetupDone, selectedRepository?.projectKey],
+  );
 
-    return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
-  };
+  const fetchData = useCallback(async () => {
+    if (!showPersonalAccessTokenForm) {
+      setIsLoadingRepositories(true);
+      const projects = await fetchBitbucketProjects().catch(() => undefined);
 
-  fetchBitbucketRepositories = (
-    projects: BitbucketProject[],
-  ): Promise<BitbucketProjectRepositories | undefined> => {
-    const { selectedAlmInstance } = this.state;
+      let projectRepositories;
+      if (projects && projects.length > 0) {
+        projectRepositories = await fetchBitbucketRepositories(projects).catch(() => undefined);
+      }
 
-    if (!selectedAlmInstance) {
-      return Promise.resolve(undefined);
+      setProjects(projects ?? []);
+      setRepositories(projectRepositories ?? {});
+      setIsLoadingRepositories(false);
     }
+  }, [
+    fetchBitbucketProjects,
+    fetchBitbucketRepositories,
+    showPersonalAccessTokenForm,
+    setIsLoadingRepositories,
+    setProjects,
+    setRepositories,
+  ]);
 
-    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 = () => {
-    this.cleanUrl();
-
-    this.setState({ showPersonalAccessTokenForm: false }, () => {
-      this.fetchInitialData();
+  const { isSearching, onSearch, onSelectRepository, searchResults } =
+    useProjectRepositorySearch<BitbucketRepository>({
+      defaultRepositorySelect,
+      fetchData,
+      fetchSearchResults: (query: string, dopKey: string) =>
+        searchForBitbucketServerRepositories(dopKey, query),
+      getRepositoryKey: ({ slug }) => slug,
+      isMonorepoSetup,
+      selectedDopSetting,
+      setSearchQuery,
+      setSelectedRepository,
+      setShowPersonalAccessTokenForm,
     });
-  };
-
-  handleImportRepository = (selectedRepository: BitbucketRepository) => {
-    const { selectedAlmInstance } = this.state;
 
-    if (selectedAlmInstance) {
-      this.props.onProjectSetupDone({
-        creationMode: CreateProjectModes.BitbucketServer,
-        almSetting: selectedAlmInstance.key,
-        monorepo: false,
-        projects: [
-          {
-            projectKey: selectedRepository.projectKey,
-            repositorySlug: selectedRepository.slug,
-          },
-        ],
-      });
+  const repositoryOptions = useMemo(() => {
+    if (searchResults) {
+      const dict = projects?.reduce((acc: Dict<BitbucketRepository[]>, { key }) => {
+        return { ...acc, [key]: searchResults?.filter((o) => o.projectKey === key) };
+      }, {});
+      return transformToOptions(projects ?? [], dict);
     }
-  };
 
-  handleSearch = (query: string) => {
-    const { selectedAlmInstance } = this.state;
+    return transformToOptions(projects ?? [], repositories);
+  }, [projects, repositories, searchResults]);
 
-    if (!selectedAlmInstance) {
-      return;
-    }
-
-    if (!query) {
-      this.setState({ searching: false, searchResults: undefined });
-      return;
-    }
-
-    this.setState({ searching: true });
-    searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
-      .then(({ repositories }) => {
-        if (this.mounted) {
-          this.setState({ searching: false, searchResults: repositories });
-        }
-      })
-      .catch(() => {
-        if (this.mounted) {
-          this.setState({ searching: false });
-        }
-      });
-  };
-
-  onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
-    this.setState({
-      selectedAlmInstance: instance,
-      showPersonalAccessTokenForm: true,
-      searching: false,
-      searchResults: undefined,
-    });
-  };
+  return isMonorepoSetup ? (
+    <MonorepoProjectCreate
+      dopSettings={dopSettings}
+      error={false}
+      loadingBindings={isLoadingBindings}
+      loadingOrganizations={false}
+      loadingRepositories={isLoadingRepositories}
+      onProjectSetupDone={handleMonorepoSetupDone}
+      onSearchRepositories={onSearch}
+      onSelectDopSetting={setSelectedDopSetting}
+      onSelectRepository={onSelectRepository}
+      personalAccessTokenComponent={
+        !isLoadingRepositories &&
+        selectedDopSetting && (
+          <BitbucketServerPersonalAccessTokenForm
+            almSetting={selectedDopSetting}
+            onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
+            resetPat={resetPersonalAccessToken}
+          />
+        )
+      }
+      repositoryOptions={repositoryOptions}
+      repositorySearchQuery={searchQuery}
+      selectedDopSetting={selectedDopSetting}
+      selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined}
+      showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
+    />
+  ) : (
+    <BitbucketCreateProjectRenderer
+      almInstances={almInstances}
+      isLoading={isLoadingRepositories || isLoadingBindings}
+      onImportRepository={handleImportRepository}
+      onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
+      onSearch={onSearch}
+      onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
+      projectRepositories={repositories}
+      projects={projects}
+      resetPat={Boolean(location.query.resetPat)}
+      searchResults={searchResults}
+      searching={isSearching}
+      selectedAlmInstance={selectedAlmInstance}
+      showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
+    />
+  );
+}
 
-  render() {
-    const { loadingBindings, location, almInstances } = this.props;
-    const {
-      selectedAlmInstance,
-      loading,
-      projectRepositories,
-      projects,
-      searching,
-      searchResults,
-      showPersonalAccessTokenForm,
-    } = this.state;
+function transformToOptions(
+  projects: BitbucketProject[],
+  repositories?: Dict<BitbucketRepository[]>,
+): Array<GroupBase<LabelValueSelectOption<string>>> {
+  return projects.map(({ name, key }) => ({
+    label: name,
+    options: repositories?.[key] !== undefined ? repositories[key].map(transformToOption) : [],
+  }));
+}
 
-    return (
-      <BitbucketCreateProjectRenderer
-        selectedAlmInstance={selectedAlmInstance}
-        almInstances={almInstances}
-        loading={loading || loadingBindings}
-        onImportRepository={this.handleImportRepository}
-        onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
-        onSearch={this.handleSearch}
-        onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
-        projectRepositories={projectRepositories}
-        projects={projects}
-        resetPat={Boolean(location.query.resetPat)}
-        searchResults={searchResults}
-        searching={searching}
-        showPersonalAccessTokenForm={
-          showPersonalAccessTokenForm || Boolean(location.query.resetPat)
-        }
-      />
-    );
-  }
+function transformToOption({ name, slug }: BitbucketRepository): LabelValueSelectOption<string> {
+  return { value: slug, label: name };
 }
index 961f319f2f4e179cbbf9b03cc11068a1c2ca2cc2..d44187b2e24251f20b98159c667c653824fbecc4 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { LightPrimary, PageContentFontWrapper, Spinner, Title } from 'design-system';
-import * as React from 'react';
+import { Link, Spinner } from '@sonarsource/echoes-react';
+import { LightPrimary, PageContentFontWrapper, Title } from 'design-system';
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { queryToSearchString } from '~sonar-aligned/helpers/urls';
+import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
 import { translate } from '../../../../helpers/l10n';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../../types/alm-integration';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
 import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Feature } from '../../../../types/features';
+import { Dict } from '../../../../types/types';
 import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
 import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import { CreateProjectModes } from '../types';
 import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
 import BitbucketServerPersonalAccessTokenForm from './BitbucketServerPersonalAccessTokenForm';
 
 export interface BitbucketProjectCreateRendererProps {
-  selectedAlmInstance?: AlmSettingsInstance;
   almInstances: AlmSettingsInstance[];
-  loading: boolean;
+  isLoading: boolean;
   onImportRepository: (repository: BitbucketRepository) => void;
   onSearch: (query: string) => void;
   onPersonalAccessTokenCreated: () => void;
   onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
   projects?: BitbucketProject[];
-  projectRepositories?: BitbucketProjectRepositories;
+  projectRepositories?: Dict<BitbucketRepository[]>;
   resetPat: boolean;
   searching: boolean;
   searchResults?: BitbucketRepository[];
+  selectedAlmInstance?: AlmSettingsInstance;
   showPersonalAccessTokenForm?: boolean;
 }
 
-export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
+export default function BitbucketProjectCreateRenderer(
+  props: Readonly<BitbucketProjectCreateRendererProps>,
+) {
   const {
     almInstances,
-    selectedAlmInstance,
-    loading,
+    isLoading,
     projects,
     projectRepositories,
-
+    resetPat,
     searching,
     searchResults,
+    selectedAlmInstance,
     showPersonalAccessTokenForm,
-    resetPat,
   } = props;
 
+  const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes(
+    Feature.MonoRepositoryPullRequestDecoration,
+  );
+
   return (
     <PageContentFontWrapper>
       <header className="sw-mb-10">
         <Title className="sw-mb-4">{translate('onboarding.create_project.bitbucket.title')}</Title>
         <LightPrimary className="sw-body-sm">
-          {translate('onboarding.create_project.bitbucket.subtitle')}
+          {isMonorepoSupported ? (
+            <FormattedMessage
+              id="onboarding.create_project.bitbucket.subtitle.with_monorepo"
+              values={{
+                monorepoSetupLink: (
+                  <Link
+                    to={{
+                      pathname: '/projects/create',
+                      search: queryToSearchString({
+                        mode: CreateProjectModes.BitbucketServer,
+                        mono: true,
+                      }),
+                    }}
+                  >
+                    <FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" />
+                  </Link>
+                ),
+              }}
+            />
+          ) : (
+            <FormattedMessage id="onboarding.create_project.bitbucket.subtitle" />
+          )}
         </LightPrimary>
       </header>
 
@@ -77,12 +106,12 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr
         onChangeConfig={props.onSelectedAlmInstanceChange}
       />
 
-      <Spinner loading={loading}>
-        {!loading && !selectedAlmInstance && (
+      <Spinner isLoading={isLoading}>
+        {!isLoading && almInstances && almInstances.length === 0 && !selectedAlmInstance && (
           <WrongBindingCountAlert alm={AlmKeys.BitbucketServer} />
         )}
 
-        {!loading &&
+        {!isLoading &&
           selectedAlmInstance &&
           (showPersonalAccessTokenForm ? (
             <BitbucketServerPersonalAccessTokenForm
index 24f51ca4686bb513fe9f13d00c65169f33af83da..003e52d1329e3372421310e4db726bbeb9d8fb16 100644 (file)
  */
 import { uniq, without } from 'lodash';
 import * as React from 'react';
-import {
-  BitbucketProject,
-  BitbucketProjectRepositories,
-  BitbucketRepository,
-} from '../../../../types/alm-integration';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import { Dict } from '../../../../types/types';
+import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
 import BitbucketProjectAccordion from './BitbucketProjectAccordion';
 
 export interface BitbucketRepositoriesProps {
   onImportRepository: (repo: BitbucketRepository) => void;
   projects: BitbucketProject[];
-  projectRepositories: BitbucketProjectRepositories;
+  projectRepositories: Dict<BitbucketRepository[]>;
 }
 
 export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
@@ -49,7 +47,7 @@ export default function BitbucketRepositories(props: BitbucketRepositoriesProps)
     <>
       {projects.map((project) => {
         const isOpen = openProjectKeys.includes(project.key);
-        const { allShown, repositories = [] } = projectRepositories[project.key] || {};
+        const repositories = projectRepositories[project.key] ?? [];
 
         return (
           <BitbucketProjectAccordion
@@ -58,7 +56,7 @@ export default function BitbucketRepositories(props: BitbucketRepositoriesProps)
             open={isOpen}
             project={project}
             repositories={repositories}
-            showingAllRepositories={allShown}
+            showingAllRepositories={repositories.length < DEFAULT_BBS_PAGE_SIZE}
             onImportRepository={props.onImportRepository}
           />
         );
index 8d9b8f3b4a4cd7617a96a5d703943a9d60dbcb0d..52bc78098811e57ac0655839eb8cb4201c5669bf 100644 (file)
@@ -30,11 +30,11 @@ import {
 import React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { translate } from '../../../../helpers/l10n';
-import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { AlmInstanceBase } from '../../../../types/alm-settings';
 import { usePersonalAccessToken } from '../usePersonalAccessToken';
 
 interface Props {
-  almSetting: AlmSettingsInstance;
+  almSetting: AlmInstanceBase;
   resetPat: boolean;
   onPersonalAccessTokenCreated: () => void;
 }
index 56ac38b6b26f8ca414def41c9da04f53fc4c2700..1422d315c3ff275498b5c2819b2af142e648f721 100644 (file)
@@ -29,7 +29,7 @@ import withAvailableFeatures, {
   WithAvailableFeaturesProps,
 } from '../../../app/components/available-features/withAvailableFeatures';
 import { translate } from '../../../helpers/l10n';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
+import { AlmKeys } from '../../../types/alm-settings';
 import { DopSetting } from '../../../types/dop-translation';
 import { Feature } from '../../../types/features';
 import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
@@ -50,7 +50,7 @@ export interface CreateProjectPageProps extends WithAvailableFeaturesProps {
 
 interface State {
   azureSettings: DopSetting[];
-  bitbucketSettings: AlmSettingsInstance[];
+  bitbucketSettings: DopSetting[];
   bitbucketCloudSettings: DopSetting[];
   githubSettings: DopSetting[];
   gitlabSettings: DopSetting[];
@@ -192,9 +192,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
       .then(({ dopSettings }) => {
         this.setState({
           azureSettings: dopSettings.filter(({ type }) => type === AlmKeys.Azure),
-          bitbucketSettings: dopSettings
-            .filter(({ type }) => type === AlmKeys.BitbucketServer)
-            .map(({ key, type, url }) => ({ alm: type, key, url })),
+          bitbucketSettings: dopSettings.filter(({ type }) => type === AlmKeys.BitbucketServer),
           bitbucketCloudSettings: dopSettings.filter(({ type }) => type === AlmKeys.BitbucketCloud),
           githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub),
           gitlabSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitLab),
@@ -250,7 +248,6 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
   };
 
   renderProjectCreation(mode?: CreateProjectModes) {
-    const { location, router } = this.props;
     const {
       azureSettings,
       bitbucketSettings,
@@ -275,10 +272,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
       case CreateProjectModes.BitbucketServer: {
         return (
           <BitbucketProjectCreate
-            almInstances={bitbucketSettings}
-            loadingBindings={loading}
-            location={location}
-            router={router}
+            dopSettings={bitbucketSettings}
+            isLoadingBindings={loading}
             onProjectSetupDone={this.handleProjectSetupDone}
           />
         );
index 1c9d5f744ca5a55f79de128aba617b572e40268c..9eab22d95f5ccbd74e59fbbfaf188bac55fa1d9b 100644 (file)
@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
 import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
 import { useProjectCreate } from '../useProjectCreate';
-import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
+import { useRepositorySearch } from '../useRepositorySearch';
 import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
 import { redirectToGithub } from './utils';
 
@@ -43,6 +43,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
   const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;
 
   const {
+    almInstances,
     handleSelectRepository,
     isInitialized,
     isLoadingOrganizations,
@@ -54,6 +55,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
     organizations,
     repositories,
     searchQuery,
+    selectedAlmInstance,
     selectedDopSetting,
     selectedRepository,
     setIsInitialized,
@@ -65,11 +67,10 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
     setSelectedOrganization,
     selectedOrganization,
     setIsLoadingOrganizations,
-  } = useProjectCreate<GithubRepository, GithubOrganization>(
+  } = useProjectCreate<GithubRepository, GithubRepository[], GithubOrganization>(
     AlmKeys.GitHub,
     dopSettings,
     ({ key }) => key,
-    REPOSITORY_PAGE_SIZE,
   );
 
   const [isInError, setIsInError] = useState(false);
@@ -79,10 +80,10 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
   const router = useRouter();
 
   const organizationOptions = useMemo(() => {
-    return organizations.map(transformToOption);
+    return organizations?.map(transformToOption);
   }, [organizations]);
   const repositoryOptions = useMemo(() => {
-    return repositories.map(transformToOption);
+    return repositories?.map(transformToOption);
   }, [repositories]);
 
   const fetchRepositories = useCallback(
@@ -104,7 +105,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
         .then(({ paging, repositories }) => {
           setProjectsPaging(paging);
           setRepositories((prevRepositories) =>
-            pageIndex === 1 ? repositories : [...prevRepositories, ...repositories],
+            pageIndex === 1 ? repositories : [...(prevRepositories ?? []), ...repositories],
           );
           setIsInitialized(true);
         })
@@ -164,7 +165,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
   const handleSelectOrganization = useCallback(
     (organizationKey: string) => {
       setSearchQuery('');
-      setSelectedOrganization(organizations.find(({ key }) => key === organizationKey));
+      setSelectedOrganization(organizations?.find(({ key }) => key === organizationKey));
     },
     [organizations, setSearchQuery, setSelectedOrganization],
   );
@@ -201,7 +202,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [selectedDopSetting]);
 
-  const { onSearch } = useProjectRepositorySearch(
+  const { onSearch } = useRepositorySearch(
     AlmKeys.GitHub,
     fetchRepositories,
     isInitialized,
@@ -232,11 +233,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
     />
   ) : (
     <GitHubProjectCreateRenderer
-      almInstances={dopSettings.map(({ key, type, url }) => ({
-        alm: type,
-        key,
-        url,
-      }))}
+      almInstances={almInstances}
       error={isInError}
       loadingBindings={isLoadingBindings}
       loadingOrganizations={isLoadingOrganizations}
@@ -250,13 +247,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
       repositories={repositories}
       repositoryPaging={projectsPaging}
       searchQuery={searchQuery}
-      selectedAlmInstance={
-        selectedDopSetting && {
-          alm: selectedDopSetting.type,
-          key: selectedDopSetting.key,
-          url: selectedDopSetting.url,
-        }
-      }
+      selectedAlmInstance={selectedAlmInstance}
       selectedOrganization={selectedOrganization}
     />
   );
index dc39a57ad889a7fefd904728a9dffeedda6f07ff..c5834b2676659eb67e58afd5679cf42d46bf1e30 100644 (file)
@@ -44,7 +44,7 @@ interface GitHubProjectCreateRendererProps {
   onLoadMore: () => void;
   onSearch: (q: string) => void;
   onSelectOrganization: (key: string) => void;
-  organizations: GithubOrganization[];
+  organizations?: GithubOrganization[];
   repositories?: GithubRepository[];
   repositoryPaging: Paging;
   searchQuery: string;
@@ -175,7 +175,7 @@ export default function GitHubProjectCreateRenderer(
             <DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
               {translate('onboarding.create_project.github.choose_organization')}
             </DarkLabel>
-            {organizations.length > 0 ? (
+            {organizations && organizations.length > 0 ? (
               <InputSelect
                 className="sw-w-7/12 sw-mb-9"
                 size="full"
index d82e0eca756d7f1a4eca90df719355d32e194c1e..6b72f0db23420a81e5b36a0f1d88c90b7ae946fd 100644 (file)
@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
 import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
 import { CreateProjectModes } from '../types';
 import { useProjectCreate } from '../useProjectCreate';
-import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
+import { useRepositorySearch } from '../useRepositorySearch';
 import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm';
 import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
 
@@ -43,6 +43,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
   const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;
 
   const {
+    almInstances,
     handlePersonalAccessTokenCreated,
     handleSelectRepository,
     isInitialized,
@@ -54,6 +55,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
     repositories,
     resetPersonalAccessToken,
     searchQuery,
+    selectedAlmInstance,
     selectedDopSetting,
     selectedRepository,
     setIsInitialized,
@@ -64,17 +66,16 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
     setSearchQuery,
     setShowPersonalAccessTokenForm,
     showPersonalAccessTokenForm,
-  } = useProjectCreate<GitlabProject, undefined>(
+  } = useProjectCreate<GitlabProject, GitlabProject[], undefined>(
     AlmKeys.GitLab,
     dopSettings,
     ({ id }) => id,
-    REPOSITORY_PAGE_SIZE,
   );
 
   const location = useLocation();
 
   const repositoryOptions = useMemo(() => {
-    return repositories.map(transformToOption);
+    return repositories?.map(transformToOption);
   }, [repositories]);
 
   const fetchRepositories = useCallback(
@@ -143,7 +144,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
     fetchRepositories(undefined, searchQuery, projectsPaging.pageIndex + 1, true);
   }, [fetchRepositories, projectsPaging, searchQuery]);
 
-  const { onSearch } = useProjectRepositorySearch(
+  const { onSearch } = useRepositorySearch(
     AlmKeys.GitLab,
     fetchRepositories,
     isInitialized,
@@ -182,11 +183,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
     />
   ) : (
     <GitlabProjectCreateRenderer
-      almInstances={dopSettings.map((dopSetting) => ({
-        alm: dopSetting.type,
-        key: dopSetting.key,
-        url: dopSetting.url,
-      }))}
+      almInstances={almInstances}
       loading={isLoadingRepositories || isLoadingBindings}
       onImport={handleImportRepository}
       onLoadMore={handleLoadMore}
@@ -197,13 +194,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
       projectsPaging={projectsPaging}
       resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
       searchQuery={searchQuery}
-      selectedAlmInstance={
-        selectedDopSetting && {
-          alm: selectedDopSetting.type,
-          key: selectedDopSetting.key,
-          url: selectedDopSetting.url,
-        }
-      }
+      selectedAlmInstance={selectedAlmInstance}
       showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
     />
   );
index 16832af1aafb2d13915863c36004c0dcc1e3bf0c..c235dce43300ed7eb787aa5519787fcfe07f9e05 100644 (file)
@@ -90,7 +90,9 @@ it('should ask for PAT when it is not set yet and show the import project featur
   expect(await screen.findByText('onboarding.create_project.azure.title')).toBeInTheDocument();
   expect(screen.getByText('alm.configuration.selector.label.alm.azure.long')).toBeInTheDocument();
 
-  expect(screen.getByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
+  await selectEvent.select(ui.instanceSelector.get(), [/conf-azure-1/]);
+
+  expect(await screen.findByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
   expect(screen.getByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();
   expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
 
@@ -135,11 +137,13 @@ it('should show import project feature when PAT is already set', async () => {
   ).toBeInTheDocument();
 
   await user.type(ui.searchbox.get(), 'repo 2');
-  expect(
-    screen.queryByRole('listitem', {
-      name: 'Azure repo 1',
-    }),
-  ).not.toBeInTheDocument();
+  await waitFor(() =>
+    expect(
+      screen.queryByRole('listitem', {
+        name: 'Azure repo 1',
+      }),
+    ).not.toBeInTheDocument(),
+  );
   expect(
     screen.queryByRole('listitem', {
       name: 'Azure repo 3',
@@ -199,7 +203,7 @@ it('should show search filter when PAT is already set', async () => {
   await user.click(inputSearch);
   await user.keyboard('s');
 
-  expect(searchAzureRepositories).toHaveBeenCalledWith('conf-azure-2', 's');
+  await waitFor(() => expect(searchAzureRepositories).toHaveBeenCalledWith('conf-azure-2', 's'));
 
   // Should search with empty results
   almIntegrationHandler.setSearchAzureRepositories([]);
index 11f52a278aa107a29ce70f25a0be3bda18210a80..8c372759e8d894a0bd8b50c45c02b6301629e4d8 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { screen, within } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
@@ -28,7 +28,9 @@ import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServi
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
+import { Feature } from '../../../../types/features';
 import CreateProjectPage from '../CreateProjectPage';
+import { CreateProjectModes } from '../types';
 
 jest.mock('../../../../api/alm-integrations');
 jest.mock('../../../../api/alm-settings');
@@ -38,7 +40,17 @@ let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
+  bitbucketServerOnboardingTitle: byRole('heading', {
+    name: 'onboarding.create_project.bitbucket.title',
+  }),
   bitbucketServerCreateProjectButton: byText('onboarding.create_project.select_method.bitbucket'),
+  cancelButton: byRole('button', { name: 'cancel' }),
+  monorepoSetupLink: byRole('link', {
+    name: 'onboarding.create_project.subtitle_monorepo_setup_link',
+  }),
+  monorepoTitle: byRole('heading', {
+    name: 'onboarding.create_project.monorepo.titlealm.bitbucket',
+  }),
   personalAccessTokenInput: byRole('textbox', {
     name: /onboarding.create_project.enter_pat/,
   }),
@@ -73,8 +85,9 @@ it('should ask for PAT when it is not set yet and show the import project featur
 
   expect(screen.getByText('onboarding.create_project.bitbucket.title')).toBeInTheDocument();
   expect(await ui.instanceSelector.find()).toBeInTheDocument();
+  await selectEvent.select(ui.instanceSelector.get(), [/conf-bitbucketserver-1/]);
 
-  expect(screen.getByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();
+  expect(await screen.findByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();
 
   expect(screen.getByRole('button', { name: 'save' })).toBeDisabled();
 
@@ -162,9 +175,11 @@ it('should show search filter when PAT is already set', async () => {
   await user.click(inputSearch);
   await user.keyboard('search');
 
-  expect(searchForBitbucketServerRepositories).toHaveBeenLastCalledWith(
-    'conf-bitbucketserver-2',
-    'search',
+  await waitFor(() =>
+    expect(searchForBitbucketServerRepositories).toHaveBeenLastCalledWith(
+      'conf-bitbucketserver-2',
+      'search',
+    ),
   );
 });
 
@@ -179,8 +194,38 @@ it('should show no result message when there are no projects', async () => {
   expect(await screen.findByText('onboarding.create_project.no_bbs_projects')).toBeInTheDocument();
 });
 
-function renderCreateProject() {
-  renderApp('project/create', <CreateProjectPage />, {
-    navigateTo: 'project/create?mode=bitbucket',
+describe('Bitbucket Server monorepo project navigation', () => {
+  it('should be able to access monorepo setup page from Bitbucket Server project import page', async () => {
+    const user = userEvent.setup();
+    renderCreateProject();
+
+    await user.click(await ui.monorepoSetupLink.find());
+
+    expect(ui.monorepoTitle.get()).toBeInTheDocument();
+  });
+
+  it('should be able to go back to Bitbucket Server onboarding page from monorepo setup page', async () => {
+    const user = userEvent.setup();
+    renderCreateProject({ isMonorepo: true });
+
+    await user.click(await ui.cancelButton.find());
+
+    expect(ui.bitbucketServerOnboardingTitle.get()).toBeInTheDocument();
+  });
+});
+
+function renderCreateProject({
+  isMonorepo = false,
+}: {
+  isMonorepo?: boolean;
+} = {}) {
+  let queryString = `mode=${CreateProjectModes.BitbucketServer}`;
+  if (isMonorepo) {
+    queryString += '&mono=true';
+  }
+
+  renderApp('projects/create', <CreateProjectPage />, {
+    navigateTo: `projects/create?${queryString}`,
+    featureList: [Feature.MonoRepositoryPullRequestDecoration],
   });
 }
index e3ecfa2ce84667812e6a5a8f2d9ff795b6349743..d9a2b7e8a403015571d290cad8f7b6505e228bc9 100644 (file)
@@ -41,29 +41,29 @@ let dopTranslationHandler: DopTranslationServiceMock;
 let newCodePeriodHandler: NewCodeDefinitionServiceMock;
 
 const ui = {
-  cancelButton: byRole('button', { name: 'cancel' }),
   bitbucketCloudCreateProjectButton: byText(
     'onboarding.create_project.select_method.bitbucketcloud',
   ),
   bitbucketCloudOnboardingTitle: byRole('heading', {
     name: 'onboarding.create_project.bitbucketcloud.title',
   }),
+  cancelButton: byRole('button', { name: 'cancel' }),
+  instanceSelector: byLabelText(/alm.configuration.selector.label/),
   monorepoSetupLink: byRole('link', {
     name: 'onboarding.create_project.subtitle_monorepo_setup_link',
   }),
   monorepoTitle: byRole('heading', {
     name: 'onboarding.create_project.monorepo.titlealm.bitbucketcloud',
   }),
+  password: byRole('textbox', {
+    name: /onboarding\.create_project\.bitbucket_cloud\.enter_password/,
+  }),
   personalAccessTokenInput: byRole('textbox', {
     name: /onboarding.create_project.enter_pat/,
   }),
-  instanceSelector: byLabelText(/alm.configuration.selector.label/),
   userName: byRole('textbox', {
     name: /onboarding\.create_project\.bitbucket_cloud\.enter_username/,
   }),
-  password: byRole('textbox', {
-    name: /onboarding\.create_project\.bitbucket_cloud\.enter_password/,
-  }),
 };
 
 const original = window.location;
index 1c6c34e3c3e2e9483e229b10645ceefdc0df6cf5..13486d0d261665a4ed7a7b9686d8bac1bbbb02f9 100644 (file)
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter';
 import { isDefined } from '../../../helpers/types';
+import {
+  AzureRepository,
+  BitbucketCloudRepository,
+  BitbucketRepository,
+  GithubRepository,
+  GitlabProject,
+} from '../../../types/alm-integration';
 import { AlmInstanceBase, AlmKeys } from '../../../types/alm-settings';
 import { DopSetting } from '../../../types/dop-translation';
-import { Paging } from '../../../types/types';
-
-export function useProjectCreate<RepoType, GroupType>(
-  almKey: AlmKeys,
-  dopSettings: DopSetting[],
-  getKey: (repo: RepoType) => string,
-  pageSize: number,
-) {
+import { Dict, Paging } from '../../../types/types';
+import { REPOSITORY_PAGE_SIZE } from './constants';
+
+type RepoTypes =
+  | AzureRepository
+  | BitbucketRepository
+  | BitbucketCloudRepository
+  | GithubRepository
+  | GitlabProject;
+type RepoCollectionTypes = Dict<RepoTypes[]> | RepoTypes[];
+
+export function useProjectCreate<
+  RepoType extends RepoTypes,
+  RepoCollectionType extends RepoCollectionTypes,
+  GroupType,
+>(almKey: AlmKeys, dopSettings: DopSetting[], getKey: (repo: RepoType) => string) {
   const [isInitialized, setIsInitialized] = useState(false);
   const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>();
   const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
-  const [organizations, setOrganizations] = useState<GroupType[]>([]);
+  const [organizations, setOrganizations] = useState<GroupType[]>();
   const [selectedOrganization, setSelectedOrganization] = useState<GroupType>();
   const [isLoadingRepositories, setIsLoadingRepositories] = useState<boolean>(false);
   const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = useState<boolean>(false);
-  const [repositories, setRepositories] = useState<RepoType[]>([]);
+  const [repositories, setRepositories] = useState<RepoCollectionType>();
   const [selectedRepository, setSelectedRepository] = useState<RepoType>();
   const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState<boolean>(true);
   const [resetPersonalAccessToken, setResetPersonalAccessToken] = useState<boolean>(false);
   const [searchQuery, setSearchQuery] = useState<string>('');
   const [projectsPaging, setProjectsPaging] = useState<Paging>({
     pageIndex: 1,
-    pageSize,
+    pageSize: REPOSITORY_PAGE_SIZE,
     total: 0,
   });
 
@@ -54,6 +69,26 @@ export function useProjectCreate<RepoType, GroupType>(
   const isMonorepoSetup = location.query?.mono === 'true';
   const hasDopSettings = useMemo(() => Boolean(dopSettings?.length), [dopSettings]);
 
+  const almInstances = useMemo(
+    () =>
+      dopSettings?.map((dopSetting) => ({
+        alm: dopSetting.type,
+        key: dopSetting.key,
+        url: dopSetting.url,
+      })) ?? [],
+    [dopSettings],
+  );
+
+  const selectedAlmInstance = useMemo(
+    () =>
+      selectedDopSetting && {
+        alm: selectedDopSetting.type,
+        key: selectedDopSetting.key,
+        url: selectedDopSetting.url,
+      },
+    [selectedDopSetting],
+  );
+
   const cleanUrl = useCallback(() => {
     delete location.query.resetPat;
     router.replace(location);
@@ -70,7 +105,7 @@ export function useProjectCreate<RepoType, GroupType>(
     setSelectedDopSetting(setting);
     setShowPersonalAccessTokenForm(true);
     setOrganizations([]);
-    setRepositories([]);
+    setRepositories(undefined);
     setSearchQuery('');
   }, []);
 
@@ -93,7 +128,16 @@ export function useProjectCreate<RepoType, GroupType>(
 
   const handleSelectRepository = useCallback(
     (repositoryKey: string) => {
-      setSelectedRepository(repositories.find((repo) => getKey(repo) === repositoryKey));
+      if (Array.isArray(repositories)) {
+        const repos = repositories as RepoType[];
+        setSelectedRepository(repos.find((repo) => getKey(repo) === repositoryKey));
+      } else {
+        const repos = repositories as Dict<RepoType[]>;
+        const selected = Object.values(repos)
+          .flat()
+          .find((repo) => getKey(repo) === repositoryKey);
+        setSelectedRepository(selected);
+      }
     },
     [getKey, repositories, setSelectedRepository],
   );
@@ -124,6 +168,7 @@ export function useProjectCreate<RepoType, GroupType>(
   }, [almKey, dopSettings, hasDopSettings, location, selectedDopSetting, setSelectedDopSetting]);
 
   return {
+    almInstances,
     handlePersonalAccessTokenCreated,
     handleSelectRepository,
     hasDopSettings,
@@ -148,6 +193,7 @@ export function useProjectCreate<RepoType, GroupType>(
     setIsLoadingOrganizations,
     setProjectsPaging,
     setOrganizations,
+    selectedAlmInstance,
     selectedOrganization,
     setRepositories,
     setResetPersonalAccessToken,
index c4b0a5ec7c15b8a264adf281302ce5ed7019463d..69adbfc788c9e57d966de953be9a8aa16c8bb765 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { AlmKeys } from '../../../types/alm-settings';
+import { isEmpty } from 'lodash';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AzureRepository, BitbucketRepository } from '../../../types/alm-integration';
 import { DopSetting } from '../../../types/dop-translation';
 import { REPOSITORY_SEARCH_DEBOUNCE_TIME } from './constants';
 
-export function useProjectRepositorySearch(
-  almKey: AlmKeys,
-  fetchRepositories: (
-    organizationKey?: string,
-    query?: string,
-    pageIndex?: number,
-    more?: boolean,
-  ) => Promise<void>,
-  isInitialized: boolean,
-  selectedDopSetting: DopSetting | undefined,
-  selectedOrganizationKey: string | undefined,
-  setSearchQuery: (query: string) => void,
-  showPersonalAccessTokenForm = false,
-) {
-  const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
-  const [isSearching, setIsSearching] = useState<boolean>(false);
-
-  const orgValid = useMemo(
-    () =>
-      almKey !== AlmKeys.GitHub ||
-      (almKey === AlmKeys.GitHub && selectedOrganizationKey !== undefined),
-    [almKey, selectedOrganizationKey],
-  );
+type RepoTypes = AzureRepository | BitbucketRepository;
 
-  useEffect(() => {
-    if (selectedDopSetting && !showPersonalAccessTokenForm && orgValid) {
-      if (almKey === AlmKeys.GitHub) {
-        fetchRepositories(selectedOrganizationKey);
-      } else if (!isInitialized) {
-        fetchRepositories();
-      }
-    }
-  }, [
-    almKey,
-    fetchRepositories,
-    isInitialized,
-    orgValid,
-    selectedDopSetting,
-    selectedOrganizationKey,
-    showPersonalAccessTokenForm,
-  ]);
+export function useProjectRepositorySearch<RepoType extends RepoTypes>({
+  defaultRepositorySelect,
+  fetchData,
+  fetchSearchResults,
+  getRepositoryKey,
+  isMonorepoSetup,
+  selectedDopSetting,
+  setSearchQuery,
+  setSelectedRepository,
+  setShowPersonalAccessTokenForm,
+}: {
+  defaultRepositorySelect: (repositoryKey: string) => void;
+  fetchData: () => void;
+  fetchSearchResults: (query: string, dopKey: string) => Promise<{ repositories: RepoType[] }>;
+  getRepositoryKey: (repo: RepoType) => string;
+  isMonorepoSetup: boolean;
+  selectedDopSetting: DopSetting | undefined;
+  setSearchQuery: (query: string) => void;
+  setSelectedRepository: (repo: RepoType) => void;
+  setShowPersonalAccessTokenForm: (show: boolean) => void;
+}) {
+  const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
+  const [isSearching, setIsSearching] = useState(false);
+  const [searchResults, setSearchResults] = useState<RepoType[] | undefined>();
 
   const onSearch = useCallback(
     (query: string) => {
       setSearchQuery(query);
-      if (!isInitialized || !orgValid) {
+      if (!selectedDopSetting) {
+        return;
+      }
+
+      if (isEmpty(query)) {
+        setSearchQuery('');
+        setSearchResults(undefined);
         return;
       }
 
       clearTimeout(repositorySearchDebounceId.current);
       repositorySearchDebounceId.current = setTimeout(() => {
         setIsSearching(true);
-        fetchRepositories(
-          almKey === AlmKeys.GitHub ? selectedOrganizationKey : undefined,
-          query,
-        ).then(
-          () => setIsSearching(false),
+        fetchSearchResults(query, selectedDopSetting.key).then(
+          ({ repositories }) => {
+            setIsSearching(false);
+            setSearchResults(repositories);
+          },
           () => setIsSearching(false),
         );
       }, REPOSITORY_SEARCH_DEBOUNCE_TIME);
     },
-    [
-      almKey,
-      fetchRepositories,
-      isInitialized,
-      orgValid,
-      repositorySearchDebounceId,
-      selectedOrganizationKey,
-      setIsSearching,
-      setSearchQuery,
-    ],
+    [fetchSearchResults, selectedDopSetting, setSearchQuery],
+  );
+
+  const onSelectRepository = useCallback(
+    (repositoryKey: string) => {
+      const repo = searchResults?.find((o) => getRepositoryKey(o) === repositoryKey);
+      if (searchResults && repo) {
+        setSelectedRepository(repo);
+      } else {
+        // If we dont have a set of search results we should look for the repository in the base set of repositories
+        defaultRepositorySelect(repositoryKey);
+      }
+    },
+    [defaultRepositorySelect, getRepositoryKey, searchResults, setSelectedRepository],
   );
 
+  useEffect(() => {
+    setSearchResults(undefined);
+    setSearchQuery('');
+    setShowPersonalAccessTokenForm(true);
+  }, [isMonorepoSetup, selectedDopSetting, setSearchQuery, setShowPersonalAccessTokenForm]);
+
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
+
   return {
     isSearching,
     onSearch,
+    onSelectRepository,
+    searchResults,
   };
 }
diff --git a/server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx b/server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx
new file mode 100644 (file)
index 0000000..d8a8fe5
--- /dev/null
@@ -0,0 +1,102 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { AlmKeys } from '../../../types/alm-settings';
+import { DopSetting } from '../../../types/dop-translation';
+import { REPOSITORY_SEARCH_DEBOUNCE_TIME } from './constants';
+
+export function useRepositorySearch(
+  almKey: AlmKeys,
+  fetchRepositories: (
+    organizationKey?: string,
+    query?: string,
+    pageIndex?: number,
+    more?: boolean,
+  ) => Promise<void>,
+  isInitialized: boolean,
+  selectedDopSetting: DopSetting | undefined,
+  selectedOrganizationKey: string | undefined,
+  setSearchQuery: (query: string) => void,
+  showPersonalAccessTokenForm = false,
+) {
+  const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
+  const [isSearching, setIsSearching] = useState<boolean>(false);
+
+  const orgValid = useMemo(
+    () =>
+      almKey !== AlmKeys.GitHub ||
+      (almKey === AlmKeys.GitHub && selectedOrganizationKey !== undefined),
+    [almKey, selectedOrganizationKey],
+  );
+
+  useEffect(() => {
+    if (selectedDopSetting && !showPersonalAccessTokenForm && orgValid) {
+      if (almKey === AlmKeys.GitHub) {
+        fetchRepositories(selectedOrganizationKey);
+      } else if (!isInitialized) {
+        fetchRepositories();
+      }
+    }
+  }, [
+    almKey,
+    fetchRepositories,
+    isInitialized,
+    orgValid,
+    selectedDopSetting,
+    selectedOrganizationKey,
+    showPersonalAccessTokenForm,
+  ]);
+
+  const onSearch = useCallback(
+    (query: string) => {
+      setSearchQuery(query);
+      if (!isInitialized || !orgValid) {
+        return;
+      }
+
+      clearTimeout(repositorySearchDebounceId.current);
+      repositorySearchDebounceId.current = setTimeout(() => {
+        setIsSearching(true);
+        fetchRepositories(
+          almKey === AlmKeys.GitHub ? selectedOrganizationKey : undefined,
+          query,
+        ).then(
+          () => setIsSearching(false),
+          () => setIsSearching(false),
+        );
+      }, REPOSITORY_SEARCH_DEBOUNCE_TIME);
+    },
+    [
+      almKey,
+      fetchRepositories,
+      isInitialized,
+      orgValid,
+      repositorySearchDebounceId,
+      selectedOrganizationKey,
+      setIsSearching,
+      setSearchQuery,
+    ],
+  );
+
+  return {
+    isSearching,
+    onSearch,
+  };
+}
index bb0980fecf1da6bd1d68425751c384848a61fd87..e3f02e76d0914cf45939aa7129f99e23625e717d 100644 (file)
@@ -4428,6 +4428,7 @@ onboarding.create_project.gitlab.link=See on GitLab
 onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator.
 onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding
 onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces
+onboarding.create_project.bitbucket.subtitle.with_monorepo=Import projects from one of your Bitbucket server workspaces or {monorepoSetupLink}.
 onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected
 onboarding.create_project.x_repository_created={count} {count, plural, one {repository} other {repositories}} will be created as {count, plural, one {a project} other {projects}} in SonarQube
 onboarding.create_project.please_dont_leave=If you leave the page the import could fail. Are you sure you want to leave?